hardening: fix command-injection in agent check_command + add double-launch guard

- check_agent() custom check_type: replace subprocess shell=True with
  shlex.split + shell=False (prevents agent-registry config from injecting
  arbitrary shell). Verified malicious '; touch' no longer executes.
- Add startup port guard in main: probe API port before uvicorn.run;
  exit 1 with clear message if already bound (stops the double-instance
  collision that caused phantom 'register does not persist' bugs).
- start_terminal_server (8082): pre-bind probe + try/except so a taken
  port logs a warning instead of crashing the whole process.
This commit is contained in:
Austin 2026-07-25 11:37:01 -07:00
parent 78eeaa9857
commit aba5120da2
2 changed files with 65 additions and 8 deletions

View File

@ -13,8 +13,13 @@
- New skills added: firebase-* (10), notion-knowledge-capture, xcode-project-setup, audit-test-plugin, test-plugin.
- `.gitignore` extended to exclude runtime artifacts (pid, graphify-out, logs).
- VERIFIED live: server on :8081, all new endpoints return 200; register/unregister persists correctly.
- CAVEAT (not yet fixed): agent `check_command` runs via `subprocess.run(..., shell=True)` — command-injection risk if a malicious agent config is registered. Local-admin-only exposure; flag for later hardening.
- CAVEAT: avoid running two `server.py` instances on the same port — registration on one won't show in reads served by the other.
- CAVEAT (FIXED 2026-07-25): agent `check_command` previously ran via `subprocess.run(..., shell=True)` — command-injection risk. Now uses `shlex.split` + `shell=False`. Verified: malicious `"; touch /tmp/PWNED"` no longer executes.
- CAVEAT (FIXED 2026-07-25): running two `server.py` on same port caused phantom "register doesn't persist" + 8082 bind crash. Added startup port guard (exits cleanly with message) + terminal websocket now binds gracefully instead of crashing. Verified: 2nd instance bails with exit 1.
## 2026-07-25 — Security & ops hardening (commit pending)
- Fixed command-injection in `check_agent()` custom check_type: `shell=True``shlex.split` + `shell=False`.
- Added double-launch guard in `main`: probe API port (8081) before `uvicorn.run`; exit 1 with clear message if already bound.
- Made `start_terminal_server` (8082) fail gracefully — pre-bind probe + try/except, logs warning instead of crashing the process.
## Archived (older than 30 days)

View File

@ -310,11 +310,20 @@ def check_agent(name: str) -> dict:
except Exception:
status = "offline" if not exists else "warning"
elif check_type == "custom":
# Run a custom check command
# Run a custom check command. IMPORTANT: never use shell=True —
# check_command comes from agent-registry config and a malicious
# value ("; rm -rf ~") would otherwise execute arbitrary shell.
check_cmd = agent.get("check_command", "")
if check_cmd:
result = subprocess.run(check_cmd, shell=True, capture_output=True, timeout=10)
status = "online" if result.returncode == 0 else "offline"
import shlex
try:
cmd = shlex.split(check_cmd)
result = subprocess.run(
cmd, shell=False, capture_output=True, timeout=10
)
status = "online" if result.returncode == 0 else "offline"
except (ValueError, OSError):
status = "offline"
else:
status = "online" if exists else "offline"
else:
@ -1889,7 +1898,25 @@ except ImportError:
def start_terminal_server(host="0.0.0.0", port=8082):
"""Start the WebSocket terminal server on a separate port."""
"""Start the WebSocket terminal server on a separate port.
If the port is already in use (e.g. a second server.py instance), fail
*gracefully* log and return instead of crashing the whole process.
"""
import socket
# Pre-check: is the port already bound by another process?
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
probe.bind((host, port))
probe.close()
except OSError as e:
print(
f"[terminal] WARNING: cannot bind {host}:{port} "
f"({e}). Terminal websocket disabled; API still running."
)
return
async def serve():
server = await websockets.serve(
terminal_handler,
@ -1897,12 +1924,16 @@ def start_terminal_server(host="0.0.0.0", port=8082):
port,
max_size=10 * 1024 * 1024,
)
print(f"[terminal] WebSocket terminal listening on ws://{host}:{port}")
return server
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server = loop.run_until_complete(serve())
loop.run_forever()
try:
server = loop.run_until_complete(serve())
loop.run_forever()
except OSError as e:
print(f"[terminal] WARNING: terminal server failed to start: {e}")
# Start terminal server in a daemon thread
@ -2025,9 +2056,30 @@ def favicon_svg():
# ─── Main ─────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
import socket
import uvicorn
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--host", type=str, default="0.0.0.0")
args = parser.parse_args()
# ── Double-launch guard ───────────────────────────────────────────
# Refuse to start if the API port is already bound by another process.
# Without this, two server.py instances collide on 8081 (and 8082),
# producing phantom "register doesn't persist" bugs and bind crashes.
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
probe.bind((args.host, args.port))
probe.close()
except OSError:
print(
f"ERROR: port {args.port} on {args.host} is already in use. "
f"Agentic OS appears to be running already — stop it first "
f"(./start.sh --stop) before launching another instance."
)
raise SystemExit(1)
uvicorn.run(app, host=args.host, port=args.port)