Architecture
June 11, 2026 · View on GitHub
Project Structure
irmia_devkit_open/
├── main.py # Plugin entry: config init, tool registration
├── config.json # User-facing config (auto-generated on first run)
├── _conf_schema.json # AstrBot WebUI config schema
├── metadata.yaml # AstrBot plugin metadata
├── pyproject.toml # Project metadata, ruff config
├── requirements.txt # Python dependencies
├── README.md / README_EN.md # User docs
├── ARCHITECTURE.md # This file
├── CHANGELOG.md # Version history
├── .gitattributes # LF line-ending enforcement
├── skills/
│ └── dev-workflow/
│ └── SKILL.md # LLM agent workflow instructions
├── tests/
│ ├── conftest.py # Shared pytest fixtures
│ ├── test_registry.py # TOOL_GROUPS ↔ _ALL_TOOLS consistency
│ ├── test_registry_static.py # Static registry checks without AstrBot
│ ├── test_helpers.py # unwrap / err_json / proposal_reply
│ ├── test_safe_edit.py # Backup → patch → syntax → rollback chain
│ ├── test_safe_write.py # New file / overwrite with syntax check
│ ├── test_file_remove.py # Path sandbox, forbidden prefixes
│ ├── test_rg_search.py # Ripgrep + Python fallback + ReDoS guard
│ ├── test_lint_runner.py # Linter fallback chain
│ ├── test_test_runner.py # Unified test runner discovery/parsing
│ ├── test_multi_edit.py # Atomic multi-file edit and rollback
│ ├── test_shell_exec.py # Allowlisted command execution
│ ├── test_op_log.py # SQLite audit trail
│ ├── test_symbol_rename.py # Python token rename
│ ├── test_syntax_check.py # Multi-language syntax + context
│ ├── test_http_utils.py # SSRF validation
│ ├── test_db_query.py # SQL injection, read-only enforcement
│ ├── test_file_zip.py # Zip-slip protection
│ ├── test_git_smart.py # Git status/diff/log/commit/push/branch guards
│ ├── test_codegraph.py # Semantic index, explore, pack, impact, BFS
│ └── test_auth.py # protect_tool, build_allowed_ids, Layer 1 filtering
└── tools/
├── __init__.py # Package marker
├── _registry.py # Tool class definitions + TOOL_GROUPS + _ALL_TOOLS
├── _helpers.py # err_json, unwrap, run_sync, proposal_reply, _run_cmd
├── _file_utils.py # read_file, find_closest_line, size limits, SymlinkGuard
├── _http_utils.py # SSRF validate_url, SafeRedirectHandler
├── _auth.py # protect_tool wrapper + build_allowed_ids
├── config.py # Module-level config singleton
├── tool_stats.py # In-memory call counter
│
├── safe_edit.py # Backup → replace → syntax check → auto-rollback
├── safe_write.py # New file / overwrite with syntax check
├── file_patch.py # Exact text replacement + preview
├── syntax_check.py # Multi-language syntax (Python/Nim/Go/JS/TS)
├── lint_runner.py # ruff/pylint/eslint with auto-fallback
├── test_runner.py # pytest/go/cargo/jest unified runner
├── multi_edit.py # Atomic multi-file edit coordinator
├── file_remove.py # Path-sandboxed file/dir deletion
├── shell_exec.py # Strict allowlisted command execution
├── op_log.py # SQLite tool-call audit trail
│
├── git_smart.py # Structured git status/diff/log/commit/push
├── git_changelog.py # Semantic git log grouping (feat/fix/docs)
├── gh_cli.py # GitHub CLI wrapper (PR/Issue/Release/Repo/CI)
│
├── es_search.py # Everything/locate/fd filename search
├── rg_search.py # File content search (rg + Python fallback)
├── dir_tree.py # Visual directory tree
├── dir_list.py # Structured directory listing
├── file_diff.py # File-to-file unified diff
├── file_hash.py # MD5/SHA1/SHA256 computation
├── file_zip.py # ZIP compress/extract with Zip-slip protection
├── config_diff.py # Key-level JSON/YAML config comparison
│
├── http_get.py # HTTP GET/POST with SSRF protection
├── http_download.py # Binary download (500MB cap + path sandbox)
├── html_extract.py # HTML → text/links/tables/CSS selector
├── json_query.py # jq-style JSON path traversal
├── text_filter.py # grep/invert/head/tail/count
├── diff_strings.py # In-memory string unified diff
├── csv_utils.py # CSV/TSV parse and generate
├── md_strip.py # Markdown → plain text
├── log_parse.py # Nginx/Apache/syslog/JSONL parser
│
├── encode_utils.py # base64/URL/hex encode/decode (used by encode_decode)
├── time_utils.py # Timestamp↔ISO, time diff (used by time)
├── semver.py # Semantic version comparison
├── uuid_gen.py # UUID4/hex/token generation
│
├── proc_list.py # Cross-platform process listing
├── sys_snapshot.py # System health snapshot (CPU/mem/uptime)
├── disk_info.py # Disk usage per partition
├── port_check.py # TCP port connectivity check
│
├── project_init.py # Project language/framework/dep detection
├── dep_scan.py # Python import graph + cycle detection
├── db_query.py # Read-only SQLite (parameterized)
├── codegraph.py # Code semantic index (AST + FTS5 + BFS)
└── symbol_rename.py # Python symbol rename via codegraph + tokenize
Initialization Flow
AstrBot loads plugin
└─ imports main.py
├─ main.py imports tools/_registry.py
│ └─ _registry imports ALL tool modules (even disabled ones)
│ └─ populates TOOL_GROUPS and _ALL_TOOLS
└─ Main.__init__()
├─ Reads config.json (→ data_dir or plugin_dir)
├─ Merges WebUI config (if present)
├─ Calls config.set_config() to inject global config
├─ Calls build_allowed_ids() to merge allowed_ids + AstrBot admins_id
├─ Filters tools: TOOL_GROUPS minus disabled_tools
├─ Instantiates enabled tool classes from _ALL_TOOLS
├─ Wraps each tool with protect_tool(tool, allowed_ids) — Layer 2 guard
└─ Calls context.add_llm_tools(*tools) to register with AstrBot
Auth Flow
LLM request arrives
├─ _auth_guard (on_llm_request hook) ← Layer 1
│ ├─ sender_id in allowed_ids? → pass
│ ├─ event.role == "admin"? → pass
│ └─ else → remove plugin tools from req.func_tool
│
└─ Tool.call() ← Layer 2
├─ guarded_call() wraps original call()
├─ sender in allowed_ids or admin? → delegate
└─ else → JSON error: "权限不足"
Tool Execution Flow
LLM decides to call "safe_edit"
└─ AstrBot routes to SafeEditTool.call()
├─ _tool_stats.record("safe_edit") # Increment counter
├─ await _run_sync(_safe_edit, filepath, old, new, replace_all, occurrence)
│ └─ run_sync() uses loop.run_in_executor(None, ...)
│ └─ Thread pool executes safe_edit.edit() synchronously
├─ _unwrap(result) # Standardize response format
│ ├─ Is it a dict? → No → err_json("非预期类型")
│ ├─ Has proposal/options/evidence? → Yes → pass through as-is
│ ├─ result["ok"] is False? → Yes → err_json(result["error"])
│ └─ Otherwise → wrap in {"ok": True, "data": result}
└─ protect_tool records op_log best-effort, then returns ToolExecResult to AstrBot
Response Protocol
All tools return a dict with at minimum {"ok": bool}. The _unwrap() function normalizes them into one of three JSON shapes:
Shape 1: Plain Success
{"ok": true, "data": {"replaced": 1, "file": "/path/to/file.py"}}
For simple successful operations. The original dict is nested under data.
Shape 2: Structured Proposal (success or failure with guidance)
{
"ok": false,
"proposal": "port 7860 is not listening",
"error": "connection refused",
"evidence": {"host": "127.0.0.1", "port": 7860},
"options": ["check if service is running", "verify port number"]
}
For failures or ambiguous results where the LLM needs to make a choice.
proposal_reply() is the factory function. _unwrap() passes these through without modification.
Shape 3: Plain Error
{"ok": false, "error": "file not found"}
For simple, unambiguous errors with no recovery path.
Config Flow
config.json (on disk) → main.py reads → _config dict
WebUI config (AstrBot) → main.py merges → _config dict
↓
config.set_config(_config, plugin_dir) # Module-level singleton
↓
Tools that need config:
safe_edit.py → get_config()["backup_dir"] # restore backup path
safe_write.py → get_config()["backup_dir"] # same backup directory
es_search.py → get_config()["es_path"] # custom es.exe location
gh_cli.py → get_config()["gh_path"] # custom gh CLI location
op_log.py → get_config()["op_log_db"] # optional audit DB override
Async Model
Two patterns exist for tool execution:
Pattern A: Thread pool (I/O-bound tools) — PREFERRED
async def call(self, context, **kwargs):
result = await _run_sync(tool_function, arg1, arg2)
return _unwrap(result)
Used by: safe_edit, safe_write, git_, http_, file_*, rg_search, syntax_check, lint_runner,
test_runner, multi_edit, shell_exec, op_log, symbol_rename, etc.
The synchronous function runs in ThreadPoolExecutor, keeping the event loop unblocked.
Pattern B: Direct call (CPU-bound, <1ms) — ACCEPTABLE
async def call(self, context, **kwargs):
return _unwrap(tool_function(arg1))
Used by: encode_decode, time, semver_compare, uuid_gen, md_strip. These operations are sub-millisecond pure computation. Direct execution is fine.
How to Add a New Tool
Step 1: Create the tool module
# tools/my_tool.py
def my_action(param: str) -> dict:
return {"ok": True, "result": f"processed: {param}"}
Step 2: Register in _registry.py
# Add import at top:
from .my_tool import my_action as _my_action
# Add class definition in the appropriate group section:
@dataclass
class MyActionTool(FunctionTool):
name: str = "my_action"
description: str = "What this tool does, when to use it."
parameters: dict = field(default_factory=lambda: {
"type": "object",
"properties": {
"param": {"type": "string", "description": "the parameter"},
},
"required": ["param"],
})
async def call(self, context, param: str, **kwargs):
_tool_stats.record(self.name)
try:
result = await _run_sync(_my_action, param)
return _unwrap(result)
except Exception as e:
return _err(f"my_action failed: {e}")
# Add to TOOL_GROUPS (pick the right group):
# e.g. under "文件系统" or create a new group
# Add to _ALL_TOOLS:
# "my_action": MyActionTool,
Step 3: Write tests
# tests/test_my_tool.py
from tools.my_tool import my_action
class TestMyTool:
def test_basic(self):
result = my_action("test")
assert result["ok"] is True
assert "test" in result["result"]
Step 4: Run tests and update docs
pytest tests/ -v
# Update README.md and README_EN.md with new tool entry
Security Architecture
SSRF Protection (_http_utils.py)
Layer 1: Direct IP check against private net ranges
Layer 2: IPv4-mapped-IPv6 extraction (::ffff:192.168.1.1)
Layer 3: DNS resolution + IP check after hostname → IP lookup
Layer 4: SafeRedirectHandler re-validates on every HTTP redirect
File Editing Safety (safe_edit.py / safe_write.py)
1. Validate file exists and is under size limit
2. Read content (UTF-8 → GBK fallback)
3. Count occurrences of old text
4. Create backup in ~/.irmia/backups/
5. Execute replacement
6. Run syntax check on code files
7. If syntax fails → auto-rollback to backup
8. Return structured result with proposal/options
SQL Injection Prevention (db_query.py)
1. Whitelist: only SELECT and PRAGMA statements
2. Mode: SQLite URI with ?mode=ro (read-only at database level)
3. Parameterized: params parameter for user input
4. Row factory: sqlite3.Row for dict-like access
ReDoS Protection
rg_search Python fallback:
- Pattern length limit (1000 chars)
- Nested quantifier detection (e.g., (a+)+) → reject
- Search step limit (500,000) → truncate
Zip-Slip Protection (file_zip.py)
1. Resolve target directory to absolute path
2. For each ZIP entry, compute (target_dir / entry_name).resolve()
3. Verify resolved path starts with target directory path + os.sep
4. Reject entries that escape the target directory
Admin Permission Enforcement (_auth.py)
Layer 1 — on_llm_request hook (_auth_guard):
1. Get sender_id from event, check against allowed_ids + event.role
2. If unauthorized → iterate req.func_tool.tools
3. Remove all tools whose handler_module_path starts with plugin prefix
4. Rebuild ToolSet with remaining tools
Layer 2 — tool call() wrapper (protect_tool):
1. Wrap original call() with guarded_call()
2. Check event.role == "admin" or sender_id in allowed_ids
3. If unauthorized → return JSON error, logger.warning
4. Any exception in guard → deny access, logger.error
Testing Strategy
Tests use real filesystems in temporary directories (Aider-style). No mocking of open(), Path, or os.
Key patterns:
sandbox_dirfixture: project-relative temp dir (avoids system-protected paths)project_dirfixture: mini Python project with imports and pycachetmp_py_file/tmp_txt_file/tmp_json_file: single-file fixtures_reset_configautouse fixture: resets global config between tests- Registry tests use
pytest.mark.skipiffor astrbot dependency
pytest tests/ -v # Run all tests
pytest tests/test_safe_edit.py -v # Run specific module
pytest tests/ -v --tb=long # Full tracebacks on failure