You are a ProClaw developer assigned to WS-D: Shell Security + Sub-Agent Execution.

  **Priority:** P0 — Launch 1 (cannot launch without this)
  **Write access:** `runner/` only
  **Read-only:** `contracts/`, `lib/`, `interceptor/`, `cli/`

  ## CONTEXT

  ProClaw is an AI agent runner with a security stack. When an agent calls `shell_exec`, it can execute arbitrary commands — including `curl` (network exfiltration), `proclaw run child.yaml` (sub-agent
  spawning), and `rm -rf /` (destruction). The shell is a security escape hatch that bypasses all policy enforcement.

  You will build TWO features:

  1. **Shell Command Pre-Screening** — Heuristic scanner that categorizes shell commands by threat type before execution
  2. **Sub-Agent Executor** — When a parent agent spawns a child agent, intercept and run it securely with inherited permission constraints

  ## DELIVERABLE 1: Shell Command Pre-Screening (~300 LOC)

  ### Create `runner/shell_scanner.py`

  ```python
  """Pre-screens shell commands for dangerous patterns before execution."""
  from __future__ import annotations

  import re
  import logging
  from enum import Enum
  from typing import Any

  from pydantic import BaseModel

  logger = logging.getLogger("proclaw.runner.shell_scanner")


  class ShellThreatCategory(str, Enum):
      """Categories of shell command threats."""
      SAFE = "safe"
      NETWORK_ACCESS = "network_access"      # curl, wget, nc, ssh, scp, python -m http
      SUB_AGENT_SPAWN = "sub_agent_spawn"    # proclaw run, openclaw run
      DESTRUCTIVE = "destructive"            # rm -rf, format, fdisk, mkfs
      PRIVILEGE_ESCALATION = "privilege_escalation"  # sudo, su, chmod 777, chown
      SECRET_ACCESS = "secret_access"        # env, printenv, cat ~/.ssh, cat .env
      DATA_EXFILTRATION = "data_exfiltration"  # base64 | curl, nc -e, reverse shells


  class ShellScanResult(BaseModel):
      """Result from scanning a shell command."""
      command: str
      categories: list[ShellThreatCategory]
      risk_level: str  # "safe", "elevated", "dangerous"
      explanation: str
      is_sub_agent: bool = False
      sub_agent_args: dict[str, Any] | None = None  # Parsed proclaw run args


  class ShellCommandScanner:
      """Scans shell commands for dangerous patterns.

      NOT a sandbox — this is heuristic pre-screening. The scanner
      categorizes commands so the policy engine can make informed
      decisions. It also detects sub-agent spawning for delegation
      to the SubAgentExecutor.
      """

      # Network access patterns
      NETWORK_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("curl/wget", re.compile(r"\b(curl|wget|httpie|http)\b", re.IGNORECASE)),
          ("netcat", re.compile(r"\b(nc|ncat|netcat|socat)\b")),
          ("ssh/scp", re.compile(r"\b(ssh|scp|sftp|rsync)\b")),
          ("python_http", re.compile(r"python[23]?\s+(-[cm]\s+)?(http|requests|urllib|aiohttp|socket)")),
          ("node_http", re.compile(r"node\s+.*\b(fetch|http|net|dgram)\b")),
          ("dns_lookup", re.compile(r"\b(dig|nslookup|host)\b")),
          ("pipe_to_remote", re.compile(r"\|\s*(curl|wget|nc|ssh)")),
      ]

      # Sub-agent spawning patterns
      SUB_AGENT_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("proclaw_run", re.compile(r"\bproclaw\s+run\b")),
          ("openclaw_run", re.compile(r"\bopenclaw\s+run\b")),
          ("python_runner", re.compile(r"python[23]?\s+.*runner[./]entrypoint")),
      ]

      # Destructive patterns
      DESTRUCTIVE_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("rm_recursive", re.compile(r"\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*\s+|.*--recursive)")),
          ("format_disk", re.compile(r"\b(mkfs|fdisk|format|dd\s+if=.*of=/dev)\b")),
          ("truncate", re.compile(r">\s*/dev/sd|>\s*/dev/nvme|truncate\s+--size\s+0")),
          ("kill_all", re.compile(r"\b(killall|pkill\s+-9|kill\s+-9\s+-1)\b")),
          ("system_shutdown", re.compile(r"\b(shutdown|reboot|init\s+0|halt)\b")),
      ]

      # Privilege escalation patterns
      PRIVILEGE_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("sudo", re.compile(r"\bsudo\b")),
          ("su_root", re.compile(r"\bsu\s+(-\s+)?root\b")),
          ("chmod_world", re.compile(r"\bchmod\s+[0-7]*7[0-7]*\b")),
          ("chown_root", re.compile(r"\bchown\s+(root|0)")),
      ]

      # Secret access patterns
      SECRET_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("env_dump", re.compile(r"\b(env|printenv|set)\b(?!\s*=)")),
          ("ssh_keys", re.compile(r"(cat|less|head|tail|more)\s+.*\.ssh/")),
          ("dotenv", re.compile(r"(cat|less|head|tail|more)\s+.*\.env\b")),
          ("credentials", re.compile(r"(cat|less|head|tail|more)\s+.*(credentials|secrets|password|token)")),
      ]

      # Data exfiltration (combination patterns)
      EXFILTRATION_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("base64_pipe", re.compile(r"base64.*\|\s*(curl|wget|nc)")),
          ("reverse_shell", re.compile(r"(bash|sh)\s+-i\s+[>|&].*(/dev/tcp|nc\s)")),
          ("encoded_payload", re.compile(r"(echo|printf)\s+.*\|\s*(base64\s+-d|python.*decode)")),
      ]

      def scan(self, command: str) -> ShellScanResult:
          """Scan a shell command and return threat assessment.

          Returns:
              ShellScanResult with categories, risk level, and explanation.
          """
          categories: list[ShellThreatCategory] = []
          explanations: list[str] = []
          sub_agent_args: dict[str, Any] | None = None

          # Check each category
          for name, pattern in self.NETWORK_PATTERNS:
              if pattern.search(command):
                  categories.append(ShellThreatCategory.NETWORK_ACCESS)
                  explanations.append(f"Network access detected ({name})")
                  break

          for name, pattern in self.SUB_AGENT_PATTERNS:
              if pattern.search(command):
                  categories.append(ShellThreatCategory.SUB_AGENT_SPAWN)
                  explanations.append(f"Sub-agent spawning detected ({name})")
                  sub_agent_args = self._parse_proclaw_run_args(command)
                  break

          for name, pattern in self.DESTRUCTIVE_PATTERNS:
              if pattern.search(command):
                  categories.append(ShellThreatCategory.DESTRUCTIVE)
                  explanations.append(f"Destructive command detected ({name})")
                  break

          for name, pattern in self.PRIVILEGE_PATTERNS:
              if pattern.search(command):
                  categories.append(ShellThreatCategory.PRIVILEGE_ESCALATION)
                  explanations.append(f"Privilege escalation detected ({name})")
                  break

          for name, pattern in self.SECRET_PATTERNS:
              if pattern.search(command):
                  categories.append(ShellThreatCategory.SECRET_ACCESS)
                  explanations.append(f"Secret access detected ({name})")
                  break

          for name, pattern in self.EXFILTRATION_PATTERNS:
              if pattern.search(command):
                  categories.append(ShellThreatCategory.DATA_EXFILTRATION)
                  explanations.append(f"Data exfiltration pattern detected ({name})")
                  break

          if not categories:
              categories = [ShellThreatCategory.SAFE]
              explanations = ["No dangerous patterns detected"]

          # Determine risk level
          if ShellThreatCategory.DATA_EXFILTRATION in categories:
              risk = "dangerous"
          elif ShellThreatCategory.DESTRUCTIVE in categories:
              risk = "dangerous"
          elif ShellThreatCategory.PRIVILEGE_ESCALATION in categories:
              risk = "dangerous"
          elif ShellThreatCategory.SUB_AGENT_SPAWN in categories:
              risk = "elevated"
          elif ShellThreatCategory.NETWORK_ACCESS in categories:
              risk = "elevated"
          elif ShellThreatCategory.SECRET_ACCESS in categories:
              risk = "elevated"
          else:
              risk = "safe"

          return ShellScanResult(
              command=command,
              categories=categories,
              risk_level=risk,
              explanation="; ".join(explanations),
              is_sub_agent=ShellThreatCategory.SUB_AGENT_SPAWN in categories,
              sub_agent_args=sub_agent_args,
          )

      def _parse_proclaw_run_args(self, command: str) -> dict[str, Any]:
          """Extract manifest path and prompt from a `proclaw run` command.

          Handles: proclaw run agent.yaml -p "prompt here"
                   proclaw run agent.yaml --prompt "prompt here"
          """
          # ... implement argument parsing
          ...

  Wire into runner/tools/shell_exec.py

  Modify ShellExecTool.execute() to run the scanner BEFORE execution:

  async def execute(self, args: dict[str, Any]) -> ToolResult:
      command = args.get("command", "")

      # Pre-screen the command
      if self._scanner is not None:
          scan_result = self._scanner.scan(command)

          # If sub-agent detected, delegate to SubAgentExecutor
          if scan_result.is_sub_agent and self._sub_agent_executor is not None:
              return await self._sub_agent_executor.execute(
                  scan_result.sub_agent_args or {},
                  parent_config=self._parent_config,
                  parent_state=self._parent_state,
                  parent_bridge=self._parent_bridge,
              )

          # Log the scan result for policy engine context
          logger.info(
              "Shell scan: risk=%s categories=%s cmd=%s",
              scan_result.risk_level,
              [c.value for c in scan_result.categories],
              command[:80],
          )

      # ... rest of existing execution logic ...

  The scanner must be injectable via the tool constructor (not hardcoded) so tests can use it without side effects.

  DELIVERABLE 2: Sub-Agent Executor (~300-400 LOC)

  Create runner/sub_agent.py

  """Secure sub-agent execution with permission inheritance.

  When a parent agent spawns a child agent (via `proclaw run child.yaml`),
  this module intercepts the command and runs the child IN-PROCESS through
  the runner pipeline with inherited security constraints.

  Security properties:
  - Child permissions = intersection(parent_permissions, child_manifest_permissions)
  - Child token budget = min(child_manifest_budget, parent_remaining_budget)
  - Child goes through SAME bridge (policy engine + DLP + audit)
  - Child session is isolated from parent session
  - Full audit trail links parent <-> child execution
  """
  from __future__ import annotations

  import logging
  from typing import Any

  from runner.models import RunnerConfig, SessionState, ToolResult

  logger = logging.getLogger("proclaw.runner.sub_agent")


  class SubAgentExecutor:
      """Executes child agents with inherited security constraints.

      This replaces running `proclaw run child.yaml` via shell_exec, which
      would bypass all security controls. Instead, the child runs through
      the same runner pipeline with permission constraints.
      """

      def __init__(
          self,
          max_depth: int = 3,  # Maximum nesting depth (parent -> child -> grandchild)
      ) -> None:
          self._max_depth = max_depth
          self._current_depth = 0  # Track nesting

      async def execute(
          self,
          sub_agent_args: dict[str, Any],
          parent_config: RunnerConfig,
          parent_state: SessionState,
          parent_bridge: Any | None = None,
      ) -> ToolResult:
          """Execute a child agent with inherited security constraints.

          Args:
              sub_agent_args: Parsed arguments from the proclaw run command.
                  Expected keys: manifest_path (str), prompt (str).
              parent_config: Parent agent's RunnerConfig.
              parent_state: Parent agent's SessionState (for token budget tracking).
              parent_bridge: Parent's security bridge (policy engine + DLP + audit).

          Returns:
              ToolResult with child agent's final response.
          """
          manifest_path = sub_agent_args.get("manifest_path", "")
          prompt = sub_agent_args.get("prompt", "")

          if not manifest_path:
              return ToolResult(
                  success=False,
                  output="",
                  error="Sub-agent execution failed: no manifest path provided",
              )

          if not prompt:
              return ToolResult(
                  success=False,
                  output="",
                  error="Sub-agent execution failed: no prompt provided",
              )

          # Check nesting depth
          self._current_depth += 1
          if self._current_depth > self._max_depth:
              self._current_depth -= 1
              return ToolResult(
                  success=False,
                  output="",
                  error=f"Sub-agent depth limit exceeded (max={self._max_depth}). "
                        f"Agents cannot spawn more than {self._max_depth} levels deep.",
              )

          try:
              return await self._run_child(
                  manifest_path=manifest_path,
                  prompt=prompt,
                  parent_config=parent_config,
                  parent_state=parent_state,
                  parent_bridge=parent_bridge,
              )
          finally:
              self._current_depth -= 1

      async def _run_child(
          self,
          manifest_path: str,
          prompt: str,
          parent_config: RunnerConfig,
          parent_state: SessionState,
          parent_bridge: Any | None,
      ) -> ToolResult:
          """Internal: run the child agent with security constraints."""
          from runner.manifest_parser import parse_manifest_file

          # Step 1: Parse child manifest
          try:
              child_manifest = parse_manifest_file(manifest_path)
          except FileNotFoundError:
              return ToolResult(
                  success=False,
                  output="",
                  error=f"Sub-agent manifest not found: {manifest_path}",
              )
          except Exception as exc:
              return ToolResult(
                  success=False,
                  output="",
                  error=f"Sub-agent manifest parse error: {exc}",
              )

          # Step 2: Enforce capability subsetting
          # Child permissions = intersection(parent, child_manifest)
          child_perms = child_manifest.agent_manifest.permissions
          parent_perms = ...  # Get from parent's loaded manifest
          # The intersection logic:
          # - child.tools.allowed = intersection of parent allowed & child allowed
          #   (if parent allows [file_read, file_write] and child requests [file_read, shell_exec],
          #    child gets only [file_read])
          # - child.filesystem.read = intersection of parent & child read paths
          # - child.filesystem.deny = union of parent & child deny paths (more restrictive)
          # - child.network.allowed_domains = intersection
          # - child.rate_limits = take the more restrictive of parent vs child
          constrained_perms = self._intersect_permissions(parent_perms, child_perms)
          child_manifest.agent_manifest.permissions = constrained_perms

          # Step 3: Calculate child's token budget
          parent_remaining = parent_config.max_tokens_total - parent_state.total_tokens
          child_budget = min(
              child_manifest.max_tokens_total,
              parent_remaining,
          )
          if child_budget <= 0:
              return ToolResult(
                  success=False,
                  output="",
                  error="Sub-agent cannot start: parent token budget exhausted",
              )

          # Step 4: Build child config
          child_config = RunnerConfig(
              max_steps=min(child_manifest.max_steps, parent_config.max_steps),
              max_tokens_total=child_budget,
              system_prompt=child_manifest.system_prompt,
              model=parent_config.model,  # Inherit parent's model
              api_key=parent_config.api_key,
              endpoint_url=parent_config.endpoint_url,
              provider=parent_config.provider,
              temperature=parent_config.temperature,
              max_completion_tokens=parent_config.max_completion_tokens,
          )

          # Step 5: Build child tool registry (only tools parent allows)
          from runner.entrypoint import build_tool_registry

          child_allowed = constrained_perms.tools.allowed or None
          child_registry = build_tool_registry(allowed=child_allowed)

          # Step 6: Build child LLM client (reuse parent's model config)
          from runner.llm_client import LiteLLMClient

          child_llm = LiteLLMClient(
              model=child_config.model,
              api_key=child_config.api_key,
              api_base=child_config.endpoint_url,
          )

          # Step 7: Load child manifest into policy engine
          if parent_bridge is not None:
              try:
                  policy_engine = getattr(parent_bridge, '_policy_client', None)
                  if policy_engine and hasattr(policy_engine, '_engine'):
                      await policy_engine._engine.load_manifest(
                          child_manifest.agent_manifest
                      )
              except Exception as exc:
                  logger.warning("Failed to load child manifest into policy engine: %s", exc)

          # Step 8: Create and run child agent
          from runner.agent_loop import AgentRunner

          child_runner = AgentRunner(
              config=child_config,
              llm_client=child_llm,
              tool_registry=child_registry,
              bridge=parent_bridge,  # SAME bridge = same policy + DLP + audit
          )
          child_runner.state.agent_id = f"{parent_state.agent_id}/child-{child_manifest.agent_manifest.agent_name}"
          child_runner.state.manifest_id = child_manifest.agent_manifest.agent_name

          logger.info(
              "Spawning sub-agent: %s (budget=%d tokens, tools=%s, depth=%d)",
              child_runner.state.agent_id,
              child_budget,
              child_registry.list_names(),
              self._current_depth,
          )

          # Step 9: Run child and capture result
          try:
              result_text = await child_runner.run(prompt)
          except Exception as exc:
              return ToolResult(
                  success=False,
                  output="",
                  error=f"Sub-agent execution failed: {exc}",
              )

          # Step 10: Debit child's token usage from parent's budget
          parent_state.add_usage(
              child_runner.state.total_prompt_tokens,
              child_runner.state.total_completion_tokens,
          )

          logger.info(
              "Sub-agent completed: %s (steps=%d, tokens=%d)",
              child_runner.state.agent_id,
              child_runner.state.step_count,
              child_runner.state.total_tokens,
          )

          return ToolResult(
              success=True,
              output=result_text,
          )

      def _intersect_permissions(self, parent_perms: Any, child_perms: Any) -> Any:
          """Compute intersection of parent and child permissions.

          The child can NEVER have more permissions than the parent.

          Rules:
          - tools.allowed = intersection (child only gets tools parent allows)
          - tools.denied = union (inherit ALL parent denials)
          - filesystem.read = intersection of paths
          - filesystem.deny = union (inherit ALL parent denials)
          - network.allowed_domains = intersection
          - network.denied_domains = union
          - rate_limits = take the more restrictive (lower) limit for each tool
          """
          from contracts.policy_contract import ManifestPermissions, ToolPermissions, FilesystemPermissions, NetworkPermissions

          # ... implement intersection logic ...
          # This is the CRITICAL security function.
          # See detailed implementation spec below.

  Permission Intersection Implementation

  The _intersect_permissions method is the core security guarantee. Implement it carefully:

  def _intersect_permissions(self, parent_perms, child_perms):
      # Tools: child only gets tools that BOTH parent and child allow
      parent_allowed = set(parent_perms.tools.allowed or [])
      child_allowed = set(child_perms.tools.allowed or [])

      if parent_allowed and child_allowed:
          final_allowed = list(parent_allowed & child_allowed)
      elif parent_allowed:
          final_allowed = list(parent_allowed)  # child requests all, parent restricts
      elif child_allowed:
          final_allowed = list(child_allowed)  # parent allows all, child restricts itself
      else:
          final_allowed = []  # both allow all

      # Denials: union (more restrictive)
      final_denied = list(
          set(parent_perms.tools.denied or []) | set(child_perms.tools.denied or [])
      )

      # Filesystem read: intersection of glob patterns
      parent_read = set(parent_perms.filesystem.read or [])
      child_read = set(child_perms.filesystem.read or [])
      final_read = list(parent_read & child_read) if parent_read and child_read else list(parent_read or child_read)

      # Filesystem deny: union
      final_fs_deny = list(
          set(parent_perms.filesystem.deny or []) | set(child_perms.filesystem.deny or [])
      )

      # Network: intersection of allowed, union of denied
      parent_net_allow = set(parent_perms.network.allowed_domains or [])
      child_net_allow = set(child_perms.network.allowed_domains or [])
      final_net_allow = list(parent_net_allow & child_net_allow) if parent_net_allow and child_net_allow else list(parent_net_allow or child_net_allow)

      final_net_deny = list(
          set(parent_perms.network.denied_domains or []) | set(child_perms.network.denied_domains or [])
      )

      # Rate limits: take the more restrictive for each tool
      # ... merge rate limit lists, keep the lower max_calls for matching tool_names ...

      return ManifestPermissions(
          tools=ToolPermissions(allowed=final_allowed, denied=final_denied),
          filesystem=FilesystemPermissions(read=final_read, write=[], deny=final_fs_deny),
          network=NetworkPermissions(allowed_domains=final_net_allow, denied_domains=final_net_deny),
          rate_limits=parent_perms.rate_limits,  # Use parent's limits as baseline
      )

  Wire into runner/entrypoint.py

  In run_agent(), create the ShellCommandScanner and SubAgentExecutor, then inject them into ShellExecTool:

  # Build shell scanner and sub-agent executor
  from runner.shell_scanner import ShellCommandScanner
  from runner.sub_agent import SubAgentExecutor

  shell_scanner = ShellCommandScanner()
  sub_agent_executor = SubAgentExecutor(max_depth=3)

  # Inject into shell_exec tool (if registered)
  shell_tool = registry.get("shell_exec")
  if shell_tool is not None:
      shell_tool._scanner = shell_scanner
      shell_tool._sub_agent_executor = sub_agent_executor
      shell_tool._parent_config = config
      shell_tool._parent_state = runner.state  # Set after runner creation
      shell_tool._parent_bridge = bridge

  Modify runner/tools/shell_exec.py

  Add scanner and sub-agent executor support to ShellExecTool:

  class ShellExecTool:
      def __init__(self):
          self._scanner: Any | None = None
          self._sub_agent_executor: Any | None = None
          self._parent_config: Any | None = None
          self._parent_state: Any | None = None
          self._parent_bridge: Any | None = None

      async def execute(self, args: dict[str, Any]) -> ToolResult:
          command = args.get("command", "")
          if not command:
              return ToolResult(success=False, output="", error="Missing required argument: command")

          # Shell pre-screening
          if self._scanner is not None:
              scan_result = self._scanner.scan(command)
              logger.info("Shell scan: %s -> %s", command[:60], scan_result.risk_level)

              # Delegate sub-agent spawning
              if scan_result.is_sub_agent and self._sub_agent_executor is not None:
                  if scan_result.sub_agent_args:
                      return await self._sub_agent_executor.execute(
                          sub_agent_args=scan_result.sub_agent_args,
                          parent_config=self._parent_config,
                          parent_state=self._parent_state,
                          parent_bridge=self._parent_bridge,
                      )
                  return ToolResult(
                      success=False,
                      output="",
                      error="Sub-agent command detected but could not parse arguments. "
                            "Use: proclaw run <manifest.yaml> -p \"<prompt>\"",
                  )

          # ... rest of existing execute() logic (unchanged) ...

  TESTS REQUIRED

  runner/tests/test_shell_scanner.py

  1. Safe command (ls, echo, cat) → SAFE, no categories
  2. curl command → NETWORK_ACCESS, elevated
  3. wget command → NETWORK_ACCESS, elevated
  4. ssh command → NETWORK_ACCESS, elevated
  5. python http.server → NETWORK_ACCESS, elevated
  6. proclaw run child.yaml -p "hi" → SUB_AGENT_SPAWN, elevated, sub_agent_args populated
  7. rm -rf / → DESTRUCTIVE, dangerous
  8. sudo anything → PRIVILEGE_ESCALATION, dangerous
  9. env / printenv → SECRET_ACCESS, elevated
  10. cat .env → SECRET_ACCESS, elevated
  11. base64 | curl → DATA_EXFILTRATION, dangerous
  12. bash -i reverse shell → DATA_EXFILTRATION, dangerous
  13. Multiple categories (sudo rm -rf) → both categories present
  14. Empty command → SAFE
  15. _parse_proclaw_run_args extracts manifest_path and prompt correctly

  runner/tests/test_sub_agent.py

  1. Successful child execution → ToolResult with child's response
  2. Missing manifest_path → error
  3. Missing prompt → error
  4. Manifest file not found → error
  5. Nesting depth exceeded (depth > max_depth) → error with explanation
  6. Token budget exhausted (parent has 0 remaining) → error
  7. Child permissions constrained by parent (child requests shell_exec but parent denies it → child can't use shell_exec)
  8. Permission intersection: tools.allowed = intersection
  9. Permission intersection: tools.denied = union
  10. Permission intersection: filesystem.deny = union
  11. Child token usage debited from parent state
  12. Child inherits parent's model and API key
  13. Child agent ID includes parent agent ID prefix
  14. Multiple sequential child spawns work correctly

  FILES YOU'LL CREATE/MODIFY

  ┌────────────────────────────────────┬─────────────────────────────────────────────────────────┐
  │                File                │                         Action                          │
  ├────────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ runner/shell_scanner.py            │ CREATE — Shell command pre-screening                    │
  ├────────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ runner/sub_agent.py                │ CREATE — Sub-agent executor with permission inheritance │
  ├────────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ runner/tools/shell_exec.py         │ MODIFY — Add scanner + sub-agent delegation             │
  ├────────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ runner/entrypoint.py               │ MODIFY — Wire scanner and executor into pipeline        │
  ├────────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ runner/tests/test_shell_scanner.py │ CREATE                                                  │
  ├────────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ runner/tests/test_sub_agent.py     │ CREATE                                                  │
  └────────────────────────────────────┴─────────────────────────────────────────────────────────┘

  EXISTING CODE YOU MUST READ FIRST

  Read these files to understand the interfaces:
  - runner/models.py — ToolResult, RunnerConfig, SessionState
  - runner/tool_protocol.py — Tool Protocol, ToolRiskLevel, ToolRegistry
  - runner/tools/shell_exec.py — Current ShellExecTool implementation
  - runner/agent_loop.py — AgentRunner class (the child will use this)
  - runner/entrypoint.py — run_agent() wiring (you modify this)
  - runner/manifest_parser.py — ParsedManifest, parse_manifest_file()
  - contracts/policy_contract.py — AgentManifest, ManifestPermissions, ToolPermissions, FilesystemPermissions, NetworkPermissions

  CRITICAL RULES

  1. All features ADDITIVE and OPTIONAL. When no scanner is configured, shell_exec behaves exactly as before. When no sub-agent executor is configured, proclaw run executes as a normal shell command (current
  behavior).
  2. Do NOT modify files outside runner/. contracts/ is READ-ONLY.
  3. The permission intersection function is the #1 security critical code. A child can NEVER have more capabilities than its parent. Test this thoroughly.
  4. Token budget is shared. Child's usage counts against parent. If parent has 1000 tokens left and child uses 800, parent only has 200 remaining.
  5. Max depth prevents infinite recursion. Default max_depth=3. A child spawning a grandchild spawning a great-grandchild is allowed. A great-grandchild spawning another is DENIED.
  6. Run existing tests: pytest runner/tests/ -v — ALL must pass.
  7. Run your new tests: pytest runner/tests/test_shell_scanner.py runner/tests/test_sub_agent.py -v
