{
  "shell.native_background": {
    "score": 1,
    "evidence": "tools/terminal_tool.py:4105-4115 advertises background=true and returns a session_id; the spawn path passes background through to the process registry at 3511. tools/process_registry.py:3244-3280 defines the model-facing process tool over those tracked ids. The terminal+process pair is in the default CLI toolset (toolsets.py:29-84, hermes_cli/config_defaults.py:12)."
  },
  "shell.timeout_cap": {
    "score": 1,
    "evidence": "Default 180s at tools/terminal_tool.py:1847; hard foreground cap FOREGROUND_MAX_TIMEOUT=600 at 119-125 (env-overridable TERMINAL_MAX_FOREGROUND_TIMEOUT); per-call timeout is model-settable in TERMINAL_SCHEMA at 4117-4121 and rejected above the cap at 2952-2957."
  },
  "shell.persistent": {
    "score": 1,
    "evidence": "Documented snapshot equivalent: tools/environments/base.py:1-6 captures env vars/functions/aliases once and re-sources before each command; tools/environments/local.py:1841-1846 ('Session snapshot preserves env vars across calls. CWD persists via file-based read after each command'). Session cwd is recorded and re-read at tools/terminal_tool.py:1251-1278 and 3622-3641."
  },
  "shell.powershell": {
    "score": 0,
    "evidence": "Searched for a selectable PowerShell shell and found none. POSIX shells are the only spawn-compatible set (tools/environments/local.py:1155-1200), and Windows requires Git Bash (tools/environments/local.py:868-945). PowerShell is referenced only for a diagnostic ASLR probe (tools/environments/local.py:984-999) and approval-pattern classification (tools/approval.py:1782-1786), not as an execution shell."
  },
  "shell.middle_out": {
    "score": 1,
    "evidence": "Head+tail splice: tools/terminal_tool.py:3692-3703 keeps 40% head / 60% tail with a middle omission notice; the streaming collector also retains a 40/60 head-tail window (tools/environments/base.py:82-98, 179-189)."
  },
  "shell.full_log": {
    "score": 1,
    "evidence": "Overflow is teed to a spill file (tools/environments/base.py:1086-1105) and the path is returned to the model as full_output_path with a truncation_note (tools/terminal_tool.py:3774-3803). Spill path is capped at 5,000,000 chars (tools/environments/base.py:91-93)."
  },
  "shell.omitted_counts": {
    "score": 1,
    "evidence": "Inline truncation names the exact omitted count and total: tools/terminal_tool.py:3699-3703. The spill result names the full size and the recovery path: tools/terminal_tool.py:3798-3803 ('Full output (N chars) saved to <path>... search it with search_files or page it with read_file')."
  },
  "shell.signal_honesty": {
    "score": 1,
    "evidence": "tools/terminal_tool.py:2514-2561 maps both subprocess negative codes and shell 128+signum codes to explicit signal notes (SIGKILL/OOM note at 2520-2521), including hedging for the ambiguous 128+N band at 2552-2559; the note is attached as exit_code_meaning at 3724 and 3843-3844. Raw exit status is never rewritten to success."
  },
  "shell.benign_exit": {
    "score": 1,
    "evidence": "tools/terminal_tool.py:2564-2640 annotates grep/rg/ag/ack=1, diff=1, find=1, test/[/1, curl codes, and git=1; the note is attached at 3724 and 3843-3844. Pipeline/chain tail-command parsing is at 2589-2603."
  },
  "shell.untrusted_fencing": {
    "score": 0,
    "evidence": "Searched for a delimiter around shell output and found none. The untrusted-data wrapper covers only web_extract/web_search, browser_*, and mcp_* (agent/tool_dispatch_helpers.py:645-668, 784-820). Terminal output is assembled and returned unwrapped at tools/terminal_tool.py:3752-3852; process output likewise at tools/process_registry.py:2119-2145 and 3344-3350."
  },
  "shell.bg_output_reader": {
    "score": 1,
    "evidence": "process(action='log') returns the background job's buffered output with line pagination: tools/process_registry.py:2147-2187, dispatched at 3346-3348. poll also returns a live output_preview at 2116-2125."
  },
  "shell.wait_modes": {
    "score": 0.5,
    "evidence": "Two of three modes exist. Return-now: process poll (tools/process_registry.py:2104-2145). Wait-for-exit: process wait blocks until exit/timeout (2189-2306). There is no wait-for-new-output mode: the schema description at 3250-3254 and action enum at 3260-3265 offer only list/poll/log/wait/kill/write/submit/close, and wait's exit path is the only successful return (2246-2258)."
  },
  "shell.cursor_reads": {
    "score": 0.5,
    "evidence": "Partial. process(action='log') paginates by a line offset (tools/process_registry.py:2147-2184; schema offset/limit at 3280-3284), so advancing the offset does not repeat lines, but there is no byte cursor or since-last-read delta, and poll returns an overlapping 1000-char tail preview on every call (2116-2125)."
  },
  "shell.list_tasks": {
    "score": 1,
    "evidence": "process(action='list') returns running and recently finished jobs with id, command, cwd, pid, uptime, status, and output preview: tools/process_registry.py:2557-2612, dispatched at 3323-3340."
  },
  "shell.kill_id": {
    "score": 1,
    "evidence": "process(action='kill') takes a session_id (prefixes accepted) and terminates the job: tools/process_registry.py:2308-2431, schema at 3287-3290, dispatch at 3351-3355."
  },
  "shell.kill_pid_port": {
    "score": 0,
    "evidence": "Searched for a model-facing pid or listening-port kill and found none. The process schema accepts only action/session_id/data/timeout/offset/limit (tools/process_registry.py:3258-3287), and _handle_process passes only session_id to kill_process (3341-3355). close_terminal also takes a process id (tools/close_terminal_tool.py:24-46). No pid/port argument exists."
  },
  "shell.sigterm_escalation": {
    "score": 1,
    "evidence": "SIGTERM, bounded grace poll, then SIGKILL: tools/process_registry.py:891-1012 (_terminate_host_pid, grace from terminal.daemon_term_grace_seconds at 873-889) and tools/environments/local.py:2110-2130 (killpg TERM, wait, killpg KILL)."
  },
  "shell.group_kill": {
    "score": 1,
    "evidence": "The local spawner creates its own session/process group and records that pgid (tools/environments/local.py:1995-2010); kills target that pgid only (2060-2135), with a comment refusing killpg for non-scope teardown at tools/process_registry.py:1254-1266. Recovered host pids are start-time verified before any signal, refusing recycled pids (tools/process_registry.py:891-945)."
  },
  "shell.monitor": {
    "score": 1,
    "evidence": "Runtime-driven output-pattern wake-ups: terminal notify=['pattern'] maps to watch_patterns (tools/terminal_tool.py:4131, 4176-4200); the reader thread scans new output and pushes a watch_match event onto completion_queue, which the CLI/gateway forge into a new turn (tools/process_registry.py:527-690). notify=true also delivers exactly one exit wake-up (tools/terminal_tool.py:4128-4133; tools/process_registry.py:1867-1980)."
  },
  "shell.sleep": {
    "score": 0.5,
    "evidence": "No dedicated sleep/park tool exists (no sleep registration in tools/*.py). Parking is only available by waiting on a background job: process(action='wait') blocks until exit/timeout and returns status='interrupted' with 'User sent a new message -- wait interrupted' on input (tools/process_registry.py:2189-2270). Partial: wake-on-input exists, but only tied to a process id."
  },
  "shell.cron": {
    "score": 1,
    "evidence": "cronjob is in the default CLI toolset (toolsets.py:29-84; hermes_cli/config_defaults.py:12) and registered at tools/cronjob_tools.py:2105-2112 with create/edit/run actions. Jobs are durable JSON on disk: cron/jobs.py:1-4 ('Jobs are stored in ~/.hermes/cron/jobs.json'), JOBS_FILE at 85, load_jobs at 1627-1645, create_job at 2186."
  },
  "shell.bg_subagents": {
    "score": 1,
    "evidence": "delegate_task runs top-level subagents in the background by default (tools/delegate_tool.py:5041-5056) and exposes action=list/steer/stop (4936-5030). Async completion is pushed onto the shared completion queue and re-enters the conversation as a new turn (tools/async_delegation.py:1-32); records are durable with a 7-day retention window (tools/async_delegation.py:70-80)."
  },
  "shell.safe_classification": {
    "score": 0.5,
    "evidence": "Partial. Dangerous-command detection is regex matching over normalized command variants (tools/approval.py:2541-2567, pattern table around 1140-1200) plus a quote-aware token lexer used to separate pipeline segments and quoted operands (tools/approval.py:1610-1660, 1680-1800). It is syntax-aware tokenization, not an AST: no bashlex/tree-sitter/shell-AST use exists in the repo."
  },
  "shell.workspace_boundary": {
    "score": 0,
    "evidence": "Searched for workspace containment on the shell and found none. workdir validation only rejects disallowed characters (tools/terminal_tool.py:403-430); there is no check that paths outside the workspace are gated. tools/path_security.py:15-33 validate_within_dir is used by skill/cron/credential paths, not by terminal execution, and the local backend accepts any host path."
  },
  "read.line_window": {
    "score": 1,
    "evidence": "READ_FILE_SCHEMA defaults limit=2000 with maximum=2000 (tools/file_tools.py:2667-2686); read_file_tool defaults to 2000 (1623) and the handler supplies a 500 fallback (2824-2826). Never returns the whole file by default."
  },
  "read.second_ceiling": {
    "score": 1,
    "evidence": "A ~100K-character budget sits on top of the line window: tools/file_tools.py:60-92 (_DEFAULT_MAX_READ_CHARS=100_000 and _get_max_read_chars) enforced at 1872-1905, returning truncated_by='bytes' and next_offset. Schema states the same ceiling at 2683."
  },
  "read.per_line_clamp": {
    "score": 1,
    "evidence": "Per-line clamp in Python at tools/file_operations.py:1115-1139 (len(line) > max_line_length -> '... [truncated]'), with a shell-side byte clamp before transport at 1601-1631 (sed | cut -b1-<4*max_line_length+1>). Default 2000 chars per line (tools/tool_output_limits.py:41)."
  },
  "read.line_prefixes": {
    "score": 1,
    "evidence": "1-indexed '<n>|content' gutter starting at the requested offset: tools/file_operations.py:1115-1139 (enumerate(start=start_line)), called with offset at 1692-1697, so numbers agree with cat -n."
  },
  "read.resume_offset": {
    "score": 1,
    "evidence": "The truncation hint names the next offset: tools/file_operations.py:1652-1656 ('Use offset=<end_line+1> to continue reading ...'). Char-budget truncation adds next_offset plus an explicit hint at tools/file_tools.py:1884-1898 and 1727-1751."
  },
  "read.empty_file_note": {
    "score": 1,
    "evidence": "A zero-byte file returns content='' with hint='File is empty (0 bytes).' rather than an error: tools/file_operations.py:1669-1679."
  },
  "read.eof_note": {
    "score": 1,
    "evidence": "An offset past EOF returns a normal ReadResult with hint naming the total and a valid retry offset, not an Error: tools/file_operations.py:1680-1690 ('Note: offset N is beyond the end of the file (M lines total). Retry with offset <= M.')."
  },
  "read.streaming": {
    "score": 1,
    "evidence": "Text reads never load the file: a stat-only size probe (tools/file_operations.py:1367-1388), a 1KB sample (1572-1582), then `sed -n 'offset,endp' | cut` (1601-1632). The stream collector itself is bounded (tools/environments/base.py:82-98, 164-189)."
  },
  "read.deferred_chunk": {
    "score": 1,
    "evidence": "Total line count is obtained before any truncation claim: tools/file_operations.py:1643-1656 computes wc -l then sets truncated = total_lines > end_line. The char-budget path trims to a complete line before reporting 'truncated' and a next_offset (tools/file_tools.py:1884-1898)."
  },
  "read.dedup": {
    "score": 0.5,
    "evidence": "Partial. An identical (path, offset, limit) on an unchanged mtime returns a stub (tools/file_tools.py:1788-1842) and is invalidated on write/compression (2069-2102, 2013-2030). But the record is NOT consumed on hit: repeated hits increment dedup_hits and then hard-block the read at 1823-1834 instead of returning content once."
  },
  "read.ledger": {
    "score": 1,
    "evidence": "Writes consult prior reads: file_state.check_stale (tools/file_state.py:142-215) is called inside the per-path write lock before write_file (tools/file_tools.py:2295-2320); it warns on never-read, sibling-subagent write, and external mtime drift. patch uses the same registry (tools/file_tools.py:2330-2440)."
  },
  "read.partial_ledger": {
    "score": 0.5,
    "evidence": "Partial. A windowed/truncated read is recorded as partial (tools/file_tools.py:1966-1970; tools/file_state.py:45-48, 93-112), and check_stale reports 'last read with offset/limit pagination (partial view). Re-read the whole file' (tools/file_state.py:201-206). But the write is only warned, not refused: the string is attached as _warning and file_ops.write_file still runs (tools/file_tools.py:2298-2308)."
  },
  "read.did_you_mean": {
    "score": 1,
    "evidence": "Misses score sibling directory entries (same stem, prefix/substring, same extension, difflib ratio >= 0.8) and return up to five similar_files (tools/file_operations.py:1744-1801); the not-found branch invokes it at 1544-1545."
  },
  "read.unicode_retry": {
    "score": 1,
    "evidence": "Before failing, a miss retries unicode-equivalent on-disk spellings (NFC/NFD, narrow no-break space, curly quote) and prepends an explanatory note: tools/file_operations.py:1527-1545 and 1700-1742."
  },
  "read.confusables": {
    "score": 0,
    "evidence": "Searched read_file's content path for zero-width/bidi disclosure and found none: file content is only redacted (tools/file_tools.py:1907-1910) and line-clamped, with no confusable scan. Invisible-unicode scans exist only for skills (tools/skills_guard.py:691-700, 756-820) and untrusted web/mcp output (tools/threat_patterns.py:142-154; agent/tool_dispatch_helpers.py:645-668), not for read_file."
  },
  "read.image_vision": {
    "score": 1,
    "evidence": "read_file detects images and redirects to vision_analyze (tools/file_tools.py:1560-1570); the native path resolves the image and returns a multimodal tool-result envelope that adapters serialize as a tool result with image (tools/vision_tools.py:1231-1248, dispatched at 1851-1870). vision_analyze is in the default toolset (toolsets.py:46, 96)."
  },
  "read.coord_scale": {
    "score": 1,
    "evidence": "A downscale publishes orig/new dimensions and _build_scale_note tells the model the exact x/y multipliers to map reported coordinates back, with a separate crop-offset note: tools/vision_tools.py:824-866."
  },
  "read.magic_bytes": {
    "score": 1,
    "evidence": "Binary typing comes from magic bytes rather than the extension: tools/file_operations.py:531-570 (identify_binary_bytes/describe_binary_file via _MAGIC_SIGNATURES) and content sniffing at 1572-1582. The image path types by magic bytes and rejects a .png of text with NotAnImage (tools/vision_tools.py:247-278; tools/image_source.py:390-416). Indirect for read_file's own image branch, which is extension-first at tools/file_operations.py:1110-1113 before the byte-typing vision check."
  },
  "read.notebooks": {
    "score": 1,
    "evidence": "Notebooks render as tagged Markdown/Code/Raw cells with per-cell output sections (tools/read_extract.py:624-670) and per-output-block bounds: 20,000-char tail truncation plus sized placeholders/omission for images, HTML, and widgets (538-620)."
  },
  "read.docx": {
    "score": 1,
    "evidence": "Stdlib DOCX extraction reads word/document.xml paragraphs, tabs, and breaks into readable text (tools/read_extract.py:677-701); routed from read_file at tools/file_tools.py:1669-1724."
  },
  "read.pptx": {
    "score": 0.5,
    "evidence": "Partial/indirect. .pptx is routed through anydoc (tools/read_extract.py:40-47, 260-300) and anydoc is a core dependency (pyproject.toml:52-59), which supports slide text. No code or test in this tree demonstrates speaker notes being emitted: grep for 'speaker' finds only skill notes, not the read/extract path."
  },
  "read.xlsx": {
    "score": 1,
    "evidence": "Stdlib XLSX extraction emits one tab-separated table per visible sheet with '# ── Sheet: <name> ──' headers (tools/read_extract.py:703-729), bounded to 5,000 rows/sheet and 256 columns (48-49, 780-800), and skips hidden sheets."
  },
  "read.odf": {
    "score": 1,
    "evidence": ".odt/.ods/.odp are in ANYDOC_EXTENSIONS (tools/read_extract.py:44-47) and converted via anydoc.to_markdown (260-300); firecrawl-anydoc is a core dependency (pyproject.toml:52-59), so the path ships by default."
  },
  "read.rtf_epub": {
    "score": 1,
    "evidence": ".rtf and .epub are in ANYDOC_EXTENSIONS (tools/read_extract.py:44-47) and converted through anydoc.to_markdown (260-300); firecrawl-anydoc is a core dependency (pyproject.toml:52-59)."
  },
  "read.pdf_text": {
    "score": 1,
    "evidence": "PDF is extracted to Markdown text inline via anydoc.to_markdown, not merely attached (tools/read_extract.py:260-300; .pdf listed at 40-47 and bundled at pyproject.toml:52-59). The returned text then flows through the normal line-window pagination (tools/file_tools.py:1669-1724)."
  },
  "read.scanned_pdf": {
    "score": 1,
    "evidence": "Scanned pages are named with a typed warning listing the page range(s) and the missing content (tools/read_extract.py:235-258), and the recovery command is given: pdftoppm -jpeg -r 150 -f <first> -l <last> ... then vision_analyze (253-255). Partial-coverage gaps get the same page-range map at 400-455."
  },
  "read.docs_windowed": {
    "score": 1,
    "evidence": "Extracted document text is split into lines and sliced with the same offset/limit window, line-numbered, and given a continue-reading hint (tools/file_tools.py:1708-1724), then the same char budget applies (1727-1751)."
  },
  "read.docs_bulk": {
    "score": 0,
    "evidence": "Documents participate only in the single-file read_file path: READ_FILE_SCHEMA has one required 'path' string and no array/batch input (tools/file_tools.py:2667-2686), and _handle_read_file reads one path (2824-2826). No bulk-read tool is registered (2899-2924)."
  },
  "read.converter_on_demand": {
    "score": 0.5,
    "evidence": "Partial. The converter is imported lazily on first use via tools/lazy_deps.py:290-298 and self-heals a lean install, so it is not loaded at startup. But pyproject.toml:52-59 declares firecrawl-anydoc==0.2.4 as a core dependency, so it is installed with the harness rather than fetched on first use."
  },
  "read.many_files": {
    "score": 0,
    "evidence": "Searched for a multi-file read and found none. read_file takes one 'path' string (tools/file_tools.py:2681-2685) and dispatches a single read_file_tool call (2824-2826); search_files searches many files but returns matches, not file contents."
  },
  "read.globs": {
    "score": 0,
    "evidence": "No glob expansion inside read_file: the schema accepts one literal path (tools/file_tools.py:2681-2685) and the resolver resolves a single path (tools/file_tools.py:373-408). Globs exist only as search_files' separate 'files' target (2810-2811), not as reads."
  },
  "read.per_file_range": {
    "score": 0,
    "evidence": "No per-file ranges because there is no bulk read: read_file accepts a single offset/limit for its single path (tools/file_tools.py:2681-2685, 2824-2826)."
  },
  "read.exclusions_gitignore": {
    "score": 0,
    "evidence": "read_file's schema exposes no exclude/gitignore inputs and no directory expansion (tools/file_tools.py:2678-2686); it reads one explicit path. .gitignore handling is confined to search/glob paths, not the read tool."
  },
  "read.aggregate_cap": {
    "score": 0,
    "evidence": "Only a per-file character budget exists (tools/file_tools.py:69-92, 1872-1905); with a single-path read there is no cross-file aggregate budget to apply."
  },
  "read.aliased_input": {
    "score": 0,
    "evidence": "Searched for argument-alias repair and found none. coerce_tool_args only coerces value types (string->int/bool, bare scalar->list, JSON-string->array/object): model_tools.py:845-950. _handle_read_file reads only the canonical path/offset/limit keys (tools/file_tools.py:2824-2826); no file_path/filepath/line-range alias mapping exists for read_file."
  },
  "read.negative_offset": {
    "score": 0,
    "evidence": "A negative offset is clamped, not treated as a tail read: normalize_read_pagination does offset = max(1, ...) (tools/file_operations.py:860-876), so offset=-20 becomes line 1."
  },
  "read.device_blocklist": {
    "score": 1,
    "evidence": "Concrete device/fd paths and sensitive /proc paths are refused before I/O: tools/file_tools.py:144-160 (_BLOCKED_DEVICE_PATHS incl. /dev/zero, /dev/random, /dev/stdin, /dev/fd/*), 524-558 (_is_blocked_device_path incl. /proc/*/environ|cmdline|maps|mem), checked at 1628-1636; special-file types (FIFO/socket/device) are also stat-refused at 1591-1620 and 1640-1655."
  }
}
