Files
arc53--docsgpt/application/devices/splitter.py
T
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

111 lines
3.5 KiB
Python

"""Compound-command splitter for safety evaluation."""
from __future__ import annotations
import shlex
import re
from typing import List
# Splitters from spec 9.4: && || ; | & |& \n
# Order matters: longest first so e.g. ``|&`` doesn't get caught by ``|``.
_SPLITTER_RE = re.compile(r"\|\&|\&\&|\|\||;|\||\&|\n")
# Wrappers stripped before extracting the head token.
_WRAPPERS = frozenset({"timeout", "nice", "nohup", "stdbuf", "xargs", "env"})
def split_command(command: str) -> List[str]:
"""Split a command string on shell connectors.
Each returned segment is a single "logical" command; the segments are
independently evaluated against the hard denylist. ``env`` is
intentionally in ``_WRAPPERS`` ONLY at the head position.
Args:
command: Raw shell command string.
Returns:
List of trimmed, non-empty segments.
"""
if not command:
return []
parts = _SPLITTER_RE.split(command)
return [p.strip() for p in parts if p and p.strip()]
def strip_wrappers(segment: str) -> str:
"""Return ``segment`` with leading command wrappers removed.
``timeout 30s rm -rf /`` → ``rm -rf /``; ``nice -n 5 rm -rf /`` →
``rm -rf /``. Wrappers and their own arguments (e.g. ``timeout 30s``,
``nice -n 5``) are dropped so safety checks see the real inner command.
``env`` is intentionally left in place so the denylist sees the head
token rather than env assignments. Returns the original segment when
there is nothing to strip or on a parse failure.
"""
if not segment:
return ""
try:
tokens = shlex.split(segment, posix=True)
except ValueError:
# Unbalanced quotes — let callers fall back to the raw segment.
return segment
if not tokens:
return ""
i = _wrapper_offset(tokens)
if i == 0:
return segment
return " ".join(tokens[i:])
def _wrapper_offset(tokens: List[str]) -> int:
"""Index of the first non-wrapper token (skipping wrapper args).
``env`` is treated as a non-wrapper so it is never stripped.
"""
i = 0
while i < len(tokens) and tokens[i] in _WRAPPERS and tokens[i] != "env":
current = tokens[i]
i += 1
if i >= len(tokens):
break
# Skip wrapper-specific arguments before the wrapped command.
if current == "timeout":
# ``timeout 30`` or ``timeout 30s`` -- skip the duration token.
if re.match(r"^\d+[smhd]?$", tokens[i]):
i += 1
elif current == "nice":
# ``nice -n 10 cmd`` -- skip ``-n`` and its value.
if tokens[i].startswith("-n"):
i += 1
if i < len(tokens) and re.match(r"^-?\d+$", tokens[i]):
i += 1
return i
def head_token(segment: str) -> str:
"""Extract the head command token from a segment, stripping wrappers.
``timeout 30s ls`` → ``ls``. ``env X=Y cmd`` is NOT stripped here.
"""
if not segment:
return ""
try:
tokens = shlex.split(segment, posix=True)
except ValueError:
# Unbalanced quotes — fall back to a naive split. Don't crash.
tokens = segment.split()
if not tokens:
return ""
i = _wrapper_offset(tokens)
if i >= len(tokens):
# Segment was only wrappers/args; nothing meaningful to return.
return tokens[0]
return tokens[i]
def head_tokens(command: str) -> List[str]:
"""Return the head token of every segment."""
return [head_token(seg) for seg in split_command(command)]