agent harness initialize repo
This commit is contained in:
15
agent-security/tools/__init__.py
Normal file
15
agent-security/tools/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from tools.registry import get_tool_registry, get_tool_schemas
|
||||
from tools.sandbox import DockerSandbox, DockerSandboxError, ACTION_TOOLS
|
||||
from tools.audit import AuditLog
|
||||
from tools.validators import ToolValidator, bounded_schemas
|
||||
|
||||
__all__ = [
|
||||
"get_tool_registry",
|
||||
"get_tool_schemas",
|
||||
"DockerSandbox",
|
||||
"DockerSandboxError",
|
||||
"ACTION_TOOLS",
|
||||
"AuditLog",
|
||||
"ToolValidator",
|
||||
"bounded_schemas",
|
||||
]
|
||||
62
agent-security/tools/_dispatch.py
Normal file
62
agent-security/tools/_dispatch.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Runs inside the sandbox container and dispatches a single tool call.
|
||||
|
||||
Invoked by the host as:
|
||||
|
||||
docker exec -i <container> python /agent_tools/_dispatch.py <tool_name>
|
||||
|
||||
The tool arguments are read from stdin as a JSON object; the result is
|
||||
printed to stdout. Only the *action* tools (the ones that touch the
|
||||
filesystem, shell, or network) are dispatched here — the in-memory
|
||||
planning tools stay on the host.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
sys.path.insert(0, "/agent_tools")
|
||||
|
||||
import filesystem # noqa: E402
|
||||
import shell # noqa: E402
|
||||
import web # noqa: E402
|
||||
|
||||
REGISTRY = {
|
||||
"read_file": filesystem.read_file,
|
||||
"glob_files": filesystem.glob_files,
|
||||
"grep": filesystem.grep,
|
||||
"write_file": filesystem.write_file,
|
||||
"edit_file": filesystem.edit_file,
|
||||
"run_bash": shell.run_bash,
|
||||
"webfetch": web.webfetch,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Error: dispatch requires a tool name argument.")
|
||||
return
|
||||
|
||||
name = sys.argv[1]
|
||||
if name not in REGISTRY:
|
||||
print(f"Error: tool '{name}' is not available inside the container.")
|
||||
return
|
||||
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
args = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: could not parse tool arguments as JSON: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = REGISTRY[name](**args)
|
||||
except Exception as e:
|
||||
result = f"Error executing tool '{
|
||||
name}' in container: {e}\n" + traceback.format_exc()
|
||||
|
||||
# The tool result is the only thing on stdout; the host captures it.
|
||||
sys.stdout.write(result if result is not None else "(no output)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
189
agent-security/tools/audit.py
Normal file
189
agent-security/tools/audit.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Append-only JSONL audit log for agent sessions.
|
||||
|
||||
Every event is written as one JSON object per line, with an auto-added
|
||||
ISO timestamp and session id. The log is flushed after every record so
|
||||
that a crash still leaves a complete trail. The file alone is enough
|
||||
to reconstruct what the agent did, in order.
|
||||
|
||||
§2.4: tool results are truncated to ``MAX_RESULT_BYTES`` in the log to
|
||||
prevent unbounded growth; a SHA-256 and full length are stored alongside
|
||||
so the truncated entry is self-describing and tamper-evident.
|
||||
|
||||
§6.1: dedicated events for every decision step —
|
||||
``log_permission_decision``, ``log_llm_request``, ``log_llm_response``,
|
||||
``log_session_abort`` — give full forensic replay without guessing.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Tool results larger than this are stored truncated in the audit log.
|
||||
MAX_RESULT_BYTES = 8 * 1024
|
||||
|
||||
|
||||
def _truncate_for_log(result: str) -> dict:
|
||||
"""Return a dict describing *result*, truncating if large.
|
||||
|
||||
The dict always has ``size`` (bytes) and ``sha256``. If the result
|
||||
is short enough it is included verbatim under ``result``; otherwise
|
||||
only the first ``MAX_RESULT_BYTES`` are kept under ``result_truncated``.
|
||||
"""
|
||||
raw = result if isinstance(result, str) else str(result)
|
||||
encoded = raw.encode("utf-8", errors="replace")
|
||||
digest = hashlib.sha256(encoded).hexdigest()
|
||||
size = len(encoded)
|
||||
if size <= MAX_RESULT_BYTES:
|
||||
return {"result": raw, "size": size, "sha256": digest}
|
||||
truncated = encoded[:MAX_RESULT_BYTES].decode("utf-8", errors="replace")
|
||||
return {
|
||||
"result_truncated": truncated,
|
||||
"truncated_from_size": size,
|
||||
"sha256": digest,
|
||||
}
|
||||
|
||||
|
||||
class AuditLog:
|
||||
"""Append-only JSONL audit log of agent activity."""
|
||||
|
||||
def __init__(self, log_dir: Path, session_id: str | None = None):
|
||||
self.log_dir = Path(log_dir)
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
self.path = self.log_dir / f"audit-{self.session_id}-{ts}.jsonl"
|
||||
self._fh = self.path.open("a", encoding="utf-8")
|
||||
self.log("session_start", log_file=str(self.path))
|
||||
|
||||
def log(self, event: str, **fields: Any) -> None:
|
||||
record = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"session": self.session_id,
|
||||
"event": event,
|
||||
}
|
||||
record.update(fields)
|
||||
self._fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
||||
self._fh.flush()
|
||||
|
||||
# -- convenience wrappers ------------------------------------------
|
||||
|
||||
def log_config(self, **fields: Any) -> None:
|
||||
"""Record the session configuration (mode, sandbox root, etc.)."""
|
||||
self.log("config", **fields)
|
||||
|
||||
def log_user_message(self, content: str) -> None:
|
||||
self.log("user_message", content=content)
|
||||
|
||||
def log_assistant_message(self, content: str | None, tool_calls) -> None:
|
||||
calls = []
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments)
|
||||
except Exception:
|
||||
args = tc.function.arguments
|
||||
calls.append({"id": tc.id, "name": tc.function.name, "args": args})
|
||||
self.log("assistant_message", content=content, tool_calls=calls)
|
||||
|
||||
def log_tool_result(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
name: str,
|
||||
args: dict,
|
||||
permission_allowed: bool,
|
||||
container_error: bool,
|
||||
container_reason: str | None,
|
||||
result: str,
|
||||
intent_drift: bool = False,
|
||||
intent_reason: str | None = None,
|
||||
permission_reason: str | None = None,
|
||||
) -> None:
|
||||
self.log(
|
||||
"tool_result",
|
||||
tool_call_id=tool_call_id,
|
||||
tool=name,
|
||||
args=args,
|
||||
permission_allowed=permission_allowed,
|
||||
permission_reason=permission_reason,
|
||||
container_error=container_error,
|
||||
container_reason=container_reason,
|
||||
intent_drift=intent_drift,
|
||||
intent_reason=intent_reason,
|
||||
**_truncate_for_log(result),
|
||||
)
|
||||
|
||||
# -- §6.1 decision-step logging ----------------------------------
|
||||
|
||||
def log_permission_decision(
|
||||
self,
|
||||
tool: str,
|
||||
args: dict,
|
||||
mode: str,
|
||||
allowed: bool,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Record a standalone permission decision (§6.1).
|
||||
|
||||
This is emitted *before* the tool runs (or is refused), so the
|
||||
audit trail shows the decision and its reason even if the
|
||||
subsequent tool execution crashes.
|
||||
"""
|
||||
self.log(
|
||||
"permission_decision",
|
||||
tool=tool,
|
||||
args=args,
|
||||
mode=mode,
|
||||
allowed=allowed,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def log_llm_request(
|
||||
self,
|
||||
model: str,
|
||||
message_count: int,
|
||||
token_estimate: int,
|
||||
has_tools: bool,
|
||||
) -> None:
|
||||
"""Record that an LLM request is about to be sent (§6.1)."""
|
||||
self.log(
|
||||
"llm_request",
|
||||
model=model,
|
||||
message_count=message_count,
|
||||
token_estimate=token_estimate,
|
||||
has_tools=has_tools,
|
||||
)
|
||||
|
||||
def log_llm_response(
|
||||
self,
|
||||
model: str,
|
||||
finish_reason: str | None,
|
||||
usage: dict | None = None,
|
||||
message_hash: str | None = None,
|
||||
tool_call_count: int = 0,
|
||||
) -> None:
|
||||
"""Record the LLM response metadata (§6.1).
|
||||
|
||||
``message_hash`` is a SHA-256 of the assistant message content
|
||||
so the full conversation can be verified for forensic replay
|
||||
without storing every token in the log.
|
||||
"""
|
||||
self.log(
|
||||
"llm_response",
|
||||
model=model,
|
||||
finish_reason=finish_reason,
|
||||
usage=usage,
|
||||
message_hash=message_hash,
|
||||
tool_call_count=tool_call_count,
|
||||
)
|
||||
|
||||
def log_session_abort(self, reason: str) -> None:
|
||||
"""Record that the session was aborted (§6.3)."""
|
||||
self.log("session_abort", reason=reason)
|
||||
|
||||
def close(self) -> None:
|
||||
self.log("session_end")
|
||||
self._fh.close()
|
||||
61
agent-security/tools/filesystem.py
Normal file
61
agent-security/tools/filesystem.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import glob as glob_module
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
|
||||
"""Read lines from a file, with optional offset and limit."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return f"Error: file not found: {path}"
|
||||
elif p.is_dir():
|
||||
return f"Error: cannot read directory content using read_file for path: {path}"
|
||||
else:
|
||||
# Proceed with file reading logic
|
||||
lines = p.read_text(errors="replace").splitlines()
|
||||
selected = lines[offset - 1: offset - 1 + limit]
|
||||
return "\n".join(f"{offset + i}: {line}" for i, line in enumerate(selected))
|
||||
|
||||
|
||||
def glob_files(pattern: str, path: str = ".") -> str:
|
||||
"""Find files matching a glob pattern inside a directory."""
|
||||
matches = glob_module.glob(f"{path}/**/{pattern}", recursive=True)
|
||||
matches += glob_module.glob(f"{path}/{pattern}")
|
||||
unique = sorted(set(matches))
|
||||
return "\n".join(unique) if unique else "(no matches)"
|
||||
|
||||
|
||||
def grep(pattern: str, path: str = ".", include: str = "*") -> str:
|
||||
"""Search file contents for a regex pattern, optionally filtering by filename glob."""
|
||||
results = []
|
||||
for filepath in glob_module.glob(f"{path}/**/{include}", recursive=True):
|
||||
fp = Path(filepath)
|
||||
if not fp.is_file():
|
||||
continue
|
||||
try:
|
||||
for i, line in enumerate(fp.read_text(errors="replace").splitlines(), 1):
|
||||
if re.search(pattern, line):
|
||||
results.append(f"{filepath}:{i}: {line}")
|
||||
except OSError:
|
||||
pass
|
||||
return "\n".join(results) if results else "(no matches)"
|
||||
|
||||
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Write content to a file, creating it if it does not exist."""
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
|
||||
|
||||
def edit_file(path: str, old_string: str, new_string: str) -> str:
|
||||
"""Replace the first occurrence of old_string with new_string in a file."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return f"Error: file not found: {path}"
|
||||
original = p.read_text()
|
||||
if old_string not in original:
|
||||
return f"Error: string not found in {path}"
|
||||
p.write_text(original.replace(old_string, new_string, 1))
|
||||
return f"Edited {path}"
|
||||
8
agent-security/tools/interaction.py
Normal file
8
agent-security/tools/interaction.py
Normal file
@@ -0,0 +1,8 @@
|
||||
def ask_question(question: str) -> str:
|
||||
"""Ask the user a clarifying question and return their answer."""
|
||||
print(f"\n [agent] {question}")
|
||||
try:
|
||||
answer = input(" Your answer: ").strip()
|
||||
except EOFError:
|
||||
return "(no answer — EOF)"
|
||||
return answer if answer else "(no answer provided)"
|
||||
282
agent-security/tools/registry.py
Normal file
282
agent-security/tools/registry.py
Normal file
@@ -0,0 +1,282 @@
|
||||
from tools.filesystem import read_file, glob_files, grep, write_file, edit_file
|
||||
from tools.shell import run_bash
|
||||
from tools.web import webfetch
|
||||
from tools.todo import todo_append, todo_list, todo_update
|
||||
from tools.scratchpad import read_scratchpad, write_scratchpad
|
||||
from tools.interaction import ask_question
|
||||
|
||||
|
||||
def get_tool_registry():
|
||||
return {
|
||||
"run_bash": run_bash,
|
||||
"read_file": read_file,
|
||||
"glob_files": glob_files,
|
||||
"grep": grep,
|
||||
"write_file": write_file,
|
||||
"edit_file": edit_file,
|
||||
"webfetch": webfetch,
|
||||
"todo_append": todo_append,
|
||||
"todo_list": todo_list,
|
||||
"todo_update": todo_update,
|
||||
"read_scratchpad": read_scratchpad,
|
||||
"write_scratchpad": write_scratchpad,
|
||||
"ask_question": ask_question,
|
||||
}
|
||||
|
||||
|
||||
def get_tool_schemas():
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_bash",
|
||||
"description": "Run a bash command on the user's machine and return the output.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute.",
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read lines from a file. Returns lines prefixed with line numbers.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Absolute or relative path to the file."},
|
||||
"offset": {"type": "integer", "description": "First line to read (1-indexed). Defaults to 1."},
|
||||
"limit": {"type": "integer", "description": "Maximum number of lines to return. Defaults to 200."},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "glob_files",
|
||||
"description": "Find files matching a glob pattern (e.g. '**/*.py') inside a directory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Glob pattern to match against file names."},
|
||||
"path": {"type": "string", "description": "Root directory to search in. Defaults to '.'."},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": "Search file contents for a regex pattern and return matching lines with file paths and line numbers.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regular expression to search for."},
|
||||
"path": {"type": "string", "description": "Directory to search in. Defaults to '.'."},
|
||||
"include": {"type": "string", "description": "Filename glob to restrict which files are searched (e.g. '*.py'). Defaults to '*'."},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "Write content to a file, creating it (and any missing parent directories) if it does not exist.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Path of the file to write."},
|
||||
"content": {"type": "string", "description": "Full content to write to the file."},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_file",
|
||||
"description": "Replace the first occurrence of a string in a file with a new string.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Path of the file to edit."},
|
||||
"old_string": {"type": "string", "description": "Exact string to find and replace."},
|
||||
"new_string": {"type": "string", "description": "String to replace it with."},
|
||||
},
|
||||
"required": ["path", "old_string", "new_string"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "webfetch",
|
||||
"description": (
|
||||
"Fetch a public URL (http/https only) and return its full plain-text content (up to 2 MB)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch (http/https)."},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_append",
|
||||
"description": (
|
||||
"Add a new item to the to-do list. "
|
||||
"Use this to track a task you plan to work on."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the item (e.g. '1', 'task-setup').",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Description of the task.",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "done", "cancelled", "failed"],
|
||||
"description": "Initial status of the item. Use 'pending' for new tasks.",
|
||||
},
|
||||
},
|
||||
"required": ["id", "content", "status"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_list",
|
||||
"description": (
|
||||
"Read the current to-do list. "
|
||||
"By default shows all active items (pending, in_progress, failed). "
|
||||
"Set include_completed=true to also see done and cancelled items. "
|
||||
"Failed items display their retry count."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_completed": {
|
||||
"type": "boolean",
|
||||
"description": "If true, include done and cancelled items in the output. Defaults to false.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_update",
|
||||
"description": (
|
||||
"Update the content or status of an existing to-do item. "
|
||||
"At least one of 'content' or 'status' must be provided. "
|
||||
"Setting a failed item back to in_progress counts as a retry and "
|
||||
"is tracked automatically. The response will warn when the retry "
|
||||
"limit is reached."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "ID of the to-do item to update.",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New description for the item. Omit to leave unchanged.",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "done", "cancelled", "failed"],
|
||||
"description": "New status for the item. Omit to leave unchanged.",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_scratchpad",
|
||||
"description": (
|
||||
"Read the current contents of the in-memory scratchpad. "
|
||||
"Returns '(empty)' if nothing has been written yet."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_scratchpad",
|
||||
"description": (
|
||||
"Overwrite the entire contents of the in-memory scratchpad with new content. "
|
||||
"The previous content is permanently replaced."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The new content to store in the scratchpad.",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ask_question",
|
||||
"description": (
|
||||
"Ask the user a clarifying question and wait for their answer. "
|
||||
"Use this when you are missing information required to complete the task "
|
||||
"and cannot reasonably infer it from context. "
|
||||
"Ask one focused question at a time. "
|
||||
"Do not use this for progress updates or to confirm actions you can already "
|
||||
"take — only ask when you are genuinely blocked."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The question to ask the user.",
|
||||
},
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
203
agent-security/tools/sandbox.py
Normal file
203
agent-security/tools/sandbox.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Docker-based sandbox for tool execution.
|
||||
|
||||
Instead of confining tools with in-process path checks and a command
|
||||
denylist (which is only as strong as the checks we remember to write),
|
||||
the action tools run inside a long-lived Docker container. The user's
|
||||
project is bind-mounted into the container; everything outside that
|
||||
mount is the container's own minimal filesystem and is invisible or
|
||||
read-only to the tool. Network egress can be disabled entirely with
|
||||
``--network none``.
|
||||
|
||||
The container is started once per session and reused for every action
|
||||
tool call (via ``docker exec``) to avoid per-call startup latency.
|
||||
In-memory planning tools (todo, scratchpad, ask_question) are **not**
|
||||
run in the container — their state would not survive between separate
|
||||
``docker exec`` processes — so they stay in-process on the host.
|
||||
|
||||
This module requires the Docker CLI on the host. On first use the
|
||||
``agent-security-runner`` image is built automatically from the
|
||||
``Dockerfile`` next to this package.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DockerSandboxError(Exception):
|
||||
"""Raised when the sandbox container cannot be used to run a tool."""
|
||||
|
||||
|
||||
# Tools that touch the outside world and therefore run in the container.
|
||||
ACTION_TOOLS = {
|
||||
"read_file",
|
||||
"glob_files",
|
||||
"grep",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"run_bash",
|
||||
"webfetch",
|
||||
}
|
||||
|
||||
DEFAULT_IMAGE = "agent-security-runner"
|
||||
# §4.3 Default per-tool-call timeout. 30 minutes was far too generous
|
||||
# and let a hanging command block the whole session. Lowered to 120 s
|
||||
# with a --tool-timeout CLI override.
|
||||
EXEC_TIMEOUT_S = 120
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
return subprocess.run(
|
||||
["docker", "info"], capture_output=True
|
||||
).returncode == 0
|
||||
|
||||
|
||||
class DockerSandbox:
|
||||
"""Manage a long-lived container that executes action tool calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_root: Path,
|
||||
tools_dir: Path,
|
||||
network: str = "bridge",
|
||||
image: str = DEFAULT_IMAGE,
|
||||
build_context: Path | None = None,
|
||||
exec_timeout: float = EXEC_TIMEOUT_S,
|
||||
container_env: dict | None = None,
|
||||
):
|
||||
if not _docker_available():
|
||||
raise DockerSandboxError(
|
||||
"Docker is not available on the host. Install Docker (or "
|
||||
"Podman aliased as docker) and ensure the daemon is running."
|
||||
)
|
||||
|
||||
self.project_root = Path(project_root).resolve()
|
||||
if not self.project_root.is_dir():
|
||||
raise DockerSandboxError(
|
||||
f"Project root is not a directory: {self.project_root}"
|
||||
)
|
||||
|
||||
self.tools_dir = Path(tools_dir).resolve()
|
||||
self.build_context = Path(build_context or self.tools_dir.parent).resolve()
|
||||
self.image = image
|
||||
self.network = network
|
||||
self.exec_timeout = float(exec_timeout)
|
||||
# §5.2: the harness — not the model — controls the container env.
|
||||
# Only an allowlist of vars is inherited from the host; secret-
|
||||
# looking env vars are stripped before the container ever starts.
|
||||
self.container_env = container_env or {}
|
||||
self.container = f"agent-sandbox-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
self._ensure_image()
|
||||
self._start_container()
|
||||
|
||||
# -- image ----------------------------------------------------------
|
||||
|
||||
def _ensure_image(self) -> None:
|
||||
inspect = subprocess.run(
|
||||
["docker", "image", "inspect", self.image],
|
||||
capture_output=True,
|
||||
)
|
||||
if inspect.returncode == 0:
|
||||
return
|
||||
dockerfile = self.build_context / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
raise DockerSandboxError(
|
||||
f"Cannot build sandbox image: Dockerfile not found at {dockerfile}."
|
||||
)
|
||||
print(f" [sandbox] building image '{self.image}' (one-time)...")
|
||||
build = subprocess.run(
|
||||
["docker", "build", "-t", self.image, str(self.build_context)],
|
||||
)
|
||||
if build.returncode != 0:
|
||||
raise DockerSandboxError(
|
||||
f"Failed to build sandbox image '{self.image}' "
|
||||
f"(docker build exited {build.returncode})."
|
||||
)
|
||||
|
||||
# -- container lifecycle -------------------------------------------
|
||||
|
||||
def _start_container(self) -> None:
|
||||
uid = os.getuid() if hasattr(os, "getuid") else 0
|
||||
gid = os.getgid() if hasattr(os, "getgid") else 0
|
||||
|
||||
cmd = [
|
||||
"docker", "run", "-d",
|
||||
"--name", self.container,
|
||||
"--network", self.network,
|
||||
"--user", f"{uid}:{gid}",
|
||||
# Mount the project at the same absolute path so paths the
|
||||
# agent reports match between host and container.
|
||||
"-v", f"{self.project_root}:{self.project_root}",
|
||||
# Mount the tool implementations read-only.
|
||||
"-v", f"{self.tools_dir}:/agent_tools:ro",
|
||||
"-w", str(self.project_root),
|
||||
]
|
||||
|
||||
# §5.2 Credential injection at the harness level: only the
|
||||
# allowlisted env vars (set by the harness, never by the model)
|
||||
# are passed to the container. Host credentials are stripped.
|
||||
for name, value in self.container_env.items():
|
||||
cmd.extend(["-e", f"{name}={value}"])
|
||||
|
||||
cmd.extend([
|
||||
"--rm",
|
||||
self.image,
|
||||
"sleep", "infinity",
|
||||
])
|
||||
run = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if run.returncode != 0:
|
||||
raise DockerSandboxError(
|
||||
f"Could not start sandbox container: {run.stderr.strip() or run.stdout.strip()}"
|
||||
)
|
||||
|
||||
# Sanity check: confirm the container is actually running.
|
||||
ps = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Running}}", self.container],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if ps.returncode != 0 or ps.stdout.strip() != "true":
|
||||
raise DockerSandboxError(
|
||||
f"Sandbox container '{self.container}' is not running after start."
|
||||
)
|
||||
|
||||
# -- tool execution ------------------------------------------------
|
||||
|
||||
def run_tool(self, name: str, args: dict) -> str:
|
||||
"""Execute *name* with *args* inside the container, return its output.
|
||||
|
||||
§4.3: enforces a per-call timeout (``self.exec_timeout``). A
|
||||
timeout is reported back as a ``DockerSandboxError`` with a
|
||||
clear message so the LLM knows not to retry blindly.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"docker", "exec", "-i",
|
||||
self.container,
|
||||
"python", "/agent_tools/_dispatch.py", name,
|
||||
],
|
||||
input=json.dumps(args),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.exec_timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise DockerSandboxError(
|
||||
f"Tool '{name}' timed out after {self.exec_timeout:.0f}s. "
|
||||
"The command did not finish in the allowed time. Do not "
|
||||
"retry the same call — adjust the approach or ask the user."
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "").strip()
|
||||
raise DockerSandboxError(
|
||||
f"Container exec for '{name}' failed (exit {proc.returncode}): {err}"
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
def close(self) -> None:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", self.container], capture_output=True
|
||||
)
|
||||
31
agent-security/tools/scratchpad.py
Normal file
31
agent-security/tools/scratchpad.py
Normal file
@@ -0,0 +1,31 @@
|
||||
class Scratchpad:
|
||||
"""Read and write from a in-memory scratchpad"""
|
||||
|
||||
def __init__(self):
|
||||
self._content = ""
|
||||
|
||||
def read(self) -> str:
|
||||
if self._content == "":
|
||||
return "(empty)"
|
||||
return self._content
|
||||
|
||||
def write(self, content: str) -> str:
|
||||
self._content = str(content).strip()
|
||||
return self._content
|
||||
|
||||
|
||||
scratchpad = Scratchpad()
|
||||
|
||||
|
||||
def read_scratchpad():
|
||||
"""Read the contents of the scratchpad"""
|
||||
return scratchpad.read()
|
||||
|
||||
|
||||
def write_scratchpad(content: str):
|
||||
"""
|
||||
Write into the scratchpad. The previous content
|
||||
will be overwritten.
|
||||
"""
|
||||
scratchpad.write(content)
|
||||
return "Successfully written content into scratchpad"
|
||||
12
agent-security/tools/shell.py
Normal file
12
agent-security/tools/shell.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
"""Run a bash command and return its output."""
|
||||
result = subprocess.run(
|
||||
command, shell=True, text=True, capture_output=True
|
||||
)
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output += f"\nSTDERR:\n{result.stderr}"
|
||||
return output or "(no output)"
|
||||
116
agent-security/tools/todo.py
Normal file
116
agent-security/tools/todo.py
Normal file
@@ -0,0 +1,116 @@
|
||||
RETRY_LIMIT = 3
|
||||
|
||||
|
||||
class ToDoList:
|
||||
"""
|
||||
Helper class to hold a to-do list in memory
|
||||
"""
|
||||
|
||||
statuses = ["pending", "in_progress", "done", "cancelled", "failed"]
|
||||
|
||||
def __init__(self):
|
||||
self._items = []
|
||||
|
||||
def read(self, include_completed=False):
|
||||
"""Read the to-do list"""
|
||||
if include_completed:
|
||||
return [item.copy() for item in self._items]
|
||||
else:
|
||||
return [item.copy() for item in self._items
|
||||
if item["status"] != "done" and item["status"] != "cancelled"]
|
||||
|
||||
def append(self, id, content, status):
|
||||
if status not in ToDoList.statuses:
|
||||
raise Exception(f"Invalid status {status}. "
|
||||
"Valid to-do statuses: pending, in_progress, done, "
|
||||
"cancelled, failed")
|
||||
if self.contains(id):
|
||||
raise Exception(f"To do item {id} already exists!")
|
||||
new_item = {"id": id, "content": content,
|
||||
"status": status, "retries": 0}
|
||||
self._items.append(new_item)
|
||||
return new_item.copy()
|
||||
|
||||
def contains(self, id) -> bool:
|
||||
"""Check if the to do list contains an item with a specific id"""
|
||||
for item in self._items:
|
||||
if item["id"] == id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def update(self, id, content, status):
|
||||
if status is not None and status not in ToDoList.statuses:
|
||||
raise Exception(f"Invalid status {status}. "
|
||||
"Valid to-do statuses: pending, in_progress, done, "
|
||||
"cancelled, failed")
|
||||
idx = 0
|
||||
while idx < len(self._items):
|
||||
if self._items[idx]["id"] == id:
|
||||
if content is not None:
|
||||
self._items[idx]["content"] = content
|
||||
if status is not None:
|
||||
prev_status = self._items[idx]["status"]
|
||||
self._items[idx]["status"] = status
|
||||
# A failed task being set back to in_progress is a retry attempt.
|
||||
if prev_status == "failed" and status == "in_progress":
|
||||
self._items[idx]["retries"] += 1
|
||||
return self._items[idx].copy()
|
||||
idx += 1
|
||||
raise Exception(f"To do item with id {id} not found")
|
||||
|
||||
|
||||
todo_store = ToDoList()
|
||||
|
||||
|
||||
def todo_append(id, content, status) -> str:
|
||||
"""Append a new to do item to the to do list"""
|
||||
id_str = str(id)
|
||||
content_str = str(content)
|
||||
status_str = str(status)
|
||||
try:
|
||||
todo_store.append(id_str, content_str, status_str)
|
||||
return f"Successfully appended to do item {id_str} in to do list!"
|
||||
except Exception as e:
|
||||
return f"Failed to append to do item: {e}"
|
||||
|
||||
|
||||
def todo_list(include_completed=False) -> str:
|
||||
"""List all the items in the to do list"""
|
||||
items = todo_store.read(include_completed)
|
||||
|
||||
result = f"To Do List ({len(items)} items)\n"
|
||||
for status in ToDoList.statuses:
|
||||
count = sum(1 for i in items if i["status"] == status)
|
||||
result += f"{count} {status} items\n"
|
||||
|
||||
result += "-----\n"
|
||||
for item in items:
|
||||
retry_note = f", {item['retries']
|
||||
} retries" if item["retries"] > 0 else ""
|
||||
result += f"- [{item['id']}] {item['content']
|
||||
} ({item['status']}{retry_note})\n"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def todo_update(id, content=None, status=None) -> str:
|
||||
if content is None and status is None:
|
||||
return "No content or status was given to update. Nothing to do."
|
||||
try:
|
||||
item = todo_store.update(id, content, status)
|
||||
retries = item["retries"]
|
||||
if item["status"] == "in_progress" and retries > 0:
|
||||
if retries >= RETRY_LIMIT:
|
||||
return (
|
||||
f"Updated to do item {id} to in_progress — "
|
||||
f"but this is retry {retries} of {
|
||||
RETRY_LIMIT} (retry limit reached). "
|
||||
f"Do not retry again. Escalate to the user instead."
|
||||
)
|
||||
return (
|
||||
f"Successfully updated to do item {id}! "
|
||||
f"Retry attempt {retries} of {RETRY_LIMIT}."
|
||||
)
|
||||
return f"Successfully updated to do item {id}!"
|
||||
except Exception as e:
|
||||
return f"Failed to update to do item {id}: {e}"
|
||||
205
agent-security/tools/validators.py
Normal file
205
agent-security/tools/validators.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Lightweight JSON-Schema validator for tool inputs (checklist §3.1, §3.3).
|
||||
|
||||
We deliberately avoid a third-party dependency (``jsonschema`` /
|
||||
``pydantic``) so the host-side validation needs no new install and no
|
||||
Docker image rebuild. This validator implements the small subset of
|
||||
JSON-Schema Draft 7 actually used by ``tools/registry.get_tool_schemas``:
|
||||
|
||||
- ``type`` (object, string, integer, boolean)
|
||||
- ``required``
|
||||
- ``properties``
|
||||
- ``enum``
|
||||
- ``minimum`` / ``maximum``
|
||||
- ``minLength`` / ``maxLength``
|
||||
|
||||
§3.3 (limit output scope) is enforced by the same schemas: bounds on
|
||||
``offset``, ``limit``, ``command`` length, ``content`` length, and a
|
||||
rejection of absolute-path glob patterns are baked into the schemas and
|
||||
therefore checked here.
|
||||
|
||||
The validator returns ``(ok, errors)`` where ``errors`` is a list of
|
||||
human-readable strings suitable for surfacing back to the LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when a tool's args fail schema validation."""
|
||||
|
||||
def __init__(self, errors: list[str]):
|
||||
super().__init__("; ".join(errors))
|
||||
self.errors = errors
|
||||
|
||||
|
||||
def _check_type(value: Any, expected: str) -> str | None:
|
||||
if expected == "object":
|
||||
if not isinstance(value, dict):
|
||||
return f"expected object, got {type(value).__name__}"
|
||||
elif expected == "string":
|
||||
if not isinstance(value, str):
|
||||
return f"expected string, got {type(value).__name__}"
|
||||
elif expected == "integer":
|
||||
# bool is a subclass of int — reject it explicitly.
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return f"expected integer, got {type(value).__name__}"
|
||||
elif expected == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
return f"expected boolean, got {type(value).__name__}"
|
||||
else:
|
||||
return f"unknown type '{expected}'"
|
||||
return None
|
||||
|
||||
|
||||
def validate_args(args: dict[str, Any], schema: dict) -> tuple[bool, list[str]]:
|
||||
"""Validate *args* against a tool's JSON-Schema function spec.
|
||||
|
||||
``schema`` is the inner ``{"type": "object", "properties": ...}``
|
||||
dict — i.e. ``tool["function"]["parameters"]``.
|
||||
|
||||
Returns ``(ok, errors)``.
|
||||
"""
|
||||
errs: list[str] = []
|
||||
|
||||
# Top-level type check.
|
||||
if "type" in schema and schema["type"] != "object":
|
||||
msg = _check_type(args, schema["type"])
|
||||
if msg:
|
||||
return False, [msg]
|
||||
|
||||
# required fields.
|
||||
required = schema.get("required", [])
|
||||
for field in required:
|
||||
if field not in args:
|
||||
errs.append(f"missing required field '{field}'")
|
||||
|
||||
properties = schema.get("properties", {})
|
||||
for name, value in args.items():
|
||||
if name not in properties:
|
||||
# Extra unknown fields are reported (strict mode). The LLM
|
||||
# should not invent parameters the schema doesn't list.
|
||||
errs.append(f"unknown field '{name}'")
|
||||
continue
|
||||
errs.extend(_validate_value(value, properties[name], name))
|
||||
|
||||
return (len(errs) == 0), errs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bounded schemas (§3.3 limit output scope)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def bounded_schemas(raw_schemas: list[dict]) -> list[dict]:
|
||||
"""Return a copy of *raw_schemas* with §3.3 bounds injected.
|
||||
|
||||
We mutate copies of the per-tool parameter schemas to add:
|
||||
|
||||
- ``read_file.offset``: minimum 1, maximum 1000000
|
||||
- ``read_file.limit``: minimum 1, maximum 2000
|
||||
- ``write_file.content``: maxLength 1_048_576 (1 MB)
|
||||
- ``edit_file.old_string`` / ``new_string``: maxLength 1_048_576
|
||||
- ``run_bash.command``: minLength 1, maxLength 4096
|
||||
- ``webfetch.url``: maxLength 4096
|
||||
- ``glob_files.pattern``: reject absolute paths (pattern check
|
||||
implemented in ``_validate_value`` via a custom constraint —
|
||||
here we add ``format: relative-path`` which our validator
|
||||
treats specially).
|
||||
|
||||
The bounds are conservative defaults; they can be tuned without
|
||||
touching the validator.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for entry in raw_schemas:
|
||||
fn = entry["function"]
|
||||
params = json.loads(json.dumps(fn["parameters"])) # deep copy
|
||||
name = fn["name"]
|
||||
props = params.setdefault("properties", {})
|
||||
|
||||
if name == "read_file":
|
||||
props.setdefault("offset", {}).setdefault("minimum", 1)
|
||||
props["offset"]["maximum"] = 1000000
|
||||
props.setdefault("limit", {}).setdefault("minimum", 1)
|
||||
props["limit"]["maximum"] = 2000
|
||||
|
||||
elif name in ("write_file", "edit_file"):
|
||||
for field in ("content", "old_string", "new_string"):
|
||||
if field in props:
|
||||
props[field]["maxLength"] = 1_048_576 # 1 MB
|
||||
|
||||
elif name == "run_bash":
|
||||
props.setdefault("command", {}).setdefault("minLength", 1)
|
||||
props["command"]["maxLength"] = 4096
|
||||
|
||||
elif name == "webfetch":
|
||||
props.setdefault("url", {}).setdefault("maxLength", 4096)
|
||||
|
||||
elif name == "glob_files":
|
||||
# Custom constraint enforced by the validator: reject
|
||||
# patterns that start with "/" (absolute path) since those
|
||||
# would escape the working-dir scoping.
|
||||
props.setdefault("pattern", {})["format"] = "relative-path"
|
||||
|
||||
out.append({"type": "function", "function": {**fn, "parameters": params}})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validator registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ToolValidator:
|
||||
"""Validate tool call arguments against their schemas."""
|
||||
|
||||
def __init__(self, schemas: list[dict]):
|
||||
self._params: dict[str, dict] = {}
|
||||
for entry in schemas:
|
||||
fn = entry["function"]
|
||||
self._params[fn["name"]] = fn["parameters"]
|
||||
|
||||
def validate(self, tool_name: str, args: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
schema = self._params.get(tool_name)
|
||||
if schema is None:
|
||||
return False, [f"unknown tool '{tool_name}'"]
|
||||
return validate_args(args, schema)
|
||||
|
||||
|
||||
def _validate_value(value: Any, schema: dict, path: str) -> list[str]:
|
||||
"""Validate a single value against its property schema."""
|
||||
errs: list[str] = []
|
||||
|
||||
if "type" in schema:
|
||||
msg = _check_type(value, schema["type"])
|
||||
if msg:
|
||||
errs.append(f"{path}: {msg}")
|
||||
return errs
|
||||
|
||||
if "enum" in schema and value not in schema["enum"]:
|
||||
errs.append(f"{path}: '{value}' is not one of {schema['enum']}")
|
||||
|
||||
if schema.get("type") == "string":
|
||||
if "minLength" in schema and len(value) < schema["minLength"]:
|
||||
errs.append(f"{path}: length {len(value)} < minLength {schema['minLength']}")
|
||||
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
||||
errs.append(f"{path}: length {len(value)} > maxLength {schema['maxLength']}")
|
||||
# §3.3 custom format: relative-path (reject absolute glob patterns).
|
||||
if schema.get("format") == "relative-path" and value.startswith("/"):
|
||||
errs.append(f"{path}: absolute paths are not allowed here (must be relative)")
|
||||
|
||||
if schema.get("type") == "integer":
|
||||
if "minimum" in schema and value < schema["minimum"]:
|
||||
errs.append(f"{path}: {value} < minimum {schema['minimum']}")
|
||||
if "maximum" in schema and value > schema["maximum"]:
|
||||
errs.append(f"{path}: {value} > maximum {schema['maximum']}")
|
||||
|
||||
return errs
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ValidationError",
|
||||
"validate_args",
|
||||
"bounded_schemas",
|
||||
"ToolValidator",
|
||||
]
|
||||
41
agent-security/tools/web.py
Normal file
41
agent-security/tools/web.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def webfetch(url: str) -> str:
|
||||
"""Fetch a URL and return its full plain-text content (up to 2 MB)."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return f"Error fetching {url}: unsupported scheme '{parsed.scheme}'. Only http and https are allowed."
|
||||
max_bytes = 2 * 1024 * 1024
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "agent/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
content_type = resp.headers.get_content_type()
|
||||
if content_type and content_type not in (
|
||||
"text/html",
|
||||
"text/plain",
|
||||
"application/xhtml+xml",
|
||||
):
|
||||
return f"Error fetching {url}: unsupported content type '{content_type}'."
|
||||
charset = resp.headers.get_content_charset() or "utf-8"
|
||||
raw_chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raw_chunks.append(chunk[: max_bytes - (total - len(chunk))])
|
||||
break
|
||||
raw_chunks.append(chunk)
|
||||
raw = b"".join(raw_chunks).decode(charset, errors="replace")
|
||||
soup = BeautifulSoup(raw, "html.parser")
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
except Exception as e:
|
||||
return f"Error fetching {url}: {e}"
|
||||
Reference in New Issue
Block a user