diff --git a/agent/jarvis-agent.py b/agent/jarvis-agent.py index 8045fc2..b74bf63 100755 --- a/agent/jarvis-agent.py +++ b/agent/jarvis-agent.py @@ -331,9 +331,9 @@ def execute_command(cmd: dict) -> dict: return {"success": True, "updated": updated} elif cmd_type == "shell": - # Only allow if explicitly enabled in config - if not cmd_data.get("allowed", False): - return {"success": False, "error": "Shell commands not enabled"} + # Guard reads LOCAL config, not the server-supplied payload + if not cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} cmd_str = cmd_data.get("command", "") r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} @@ -359,15 +359,13 @@ def main(): poll_interval = int(cfg.get("poll_interval", 30)) heartbeat_every = int(cfg.get("heartbeat_every", 10)) - # Register if no API key yet + # Register if no API key yet — loop (not recurse) to avoid stack overflow api_key = state.get("api_key", "") - if not api_key: + while not api_key: api_key = register(cfg, state) if not api_key: print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) time.sleep(60) - main() - return headers = {"X-Agent-Key": api_key} last_metrics = 0 @@ -424,7 +422,9 @@ def main(): # ── Self-update ──────────────────────────────────────────────────────────────── def self_update(cfg: dict) -> bool: - """Check JARVIS server for a newer version of this script. If different, replace and restart.""" + """Check JARVIS server for a newer version of this script. + Verifies SHA-256 hash from .sha256 before replacing.""" + import hashlib jarvis_url = cfg.get("jarvis_url", "").rstrip("/") default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" update_url = cfg.get("update_url", default_update_url) @@ -432,14 +432,37 @@ def self_update(cfg: dict) -> bool: return False script_path = os.path.abspath(__file__) try: + # Download expected hash first + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req_hash.add_header("Host", _host_header) + try: + with urllib.request.urlopen(req_hash, timeout=10) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + expected_hash = None + + # Download new script req = urllib.request.Request(update_url) req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) with urllib.request.urlopen(req, timeout=30) as resp: new_content = resp.read() + + # Verify hash if available — abort if mismatch + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + print(f"[JARVIS] Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting", flush=True) + return False + with open(script_path, "rb") as f: current = f.read() if new_content != current: - print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) + print(f"[JARVIS] Update verified — replacing {script_path} and restarting...", flush=True) with open(script_path, "wb") as f: f.write(new_content) os.execv(sys.executable, [sys.executable] + sys.argv) diff --git a/api/endpoints/agent.php b/api/endpoints/agent.php index 5ffe940..c192576 100644 --- a/api/endpoints/agent.php +++ b/api/endpoints/agent.php @@ -53,7 +53,7 @@ if ($agentAction !== 'register') { if (in_array($agentAction, $browserActions)) { $token = $_SESSION['jarvis_token'] ?? ''; $localIP = $_SERVER['REMOTE_ADDR'] ?? ''; - if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1', JARVIS_IP])) { + if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1'])) { agent_error(401, 'Unauthorized'); } $agent = null; diff --git a/deploy/jarvis-deploy.sh b/deploy/jarvis-deploy.sh index 0f43064..f3b4296 100755 --- a/deploy/jarvis-deploy.sh +++ b/deploy/jarvis-deploy.sh @@ -13,9 +13,12 @@ log() { echo "[$(TS)] $1" >> "$LOG"; } [ ! -f "$QUEUE" ] && exit 0 [ ! -s "$QUEUE" ] && exit 0 -# Snapshot and clear queue atomically -SNAPSHOT=$(cat "$QUEUE") -> "$QUEUE" +# Atomically take ownership of the queue via rename — prevents TOCTOU loss of +# entries written between a cat and truncate +PROCESSING="${QUEUE}.processing" +mv "$QUEUE" "$PROCESSING" 2>/dev/null || exit 0 +SNAPSHOT=$(cat "$PROCESSING") +rm -f "$PROCESSING" while IFS= read -r path; do [ -z "$path" ] && continue @@ -51,13 +54,20 @@ while IFS= read -r path; do done <<< "$CHANGED" if [ "$SYNTAX_OK" = false ]; then - log "SYNTAX ERROR in $BAD_FILE — reverting to $BEFORE" + log "SYNTAX ERROR in $BAD_FILE — reverting locally and pushing revert to GitHub" git reset --hard "$BEFORE" >> "$LOG" 2>&1 + # Push the revert so GitHub matches the live server — prevents infinite re-deploy loop + git push --force origin HEAD:main >> "$LOG" 2>&1 + PUSH_EXIT=$? + if [ $PUSH_EXIT -ne 0 ]; then + log "WARNING: Force-push of revert failed (exit $PUSH_EXIT) — bad commit still on GitHub" + fi # Insert alert into JARVIS DB + BAD_ESCAPED=$(printf '%s' "$BAD_FILE" | sed "s/'/\\\\\\'/g") mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se \ "INSERT INTO alerts (alert_type,title,message,severity) VALUES ('deploy_fail','Deploy reverted: syntax error', - 'PHP syntax error in $BAD_FILE. Commit $AFTER was reverted automatically.','critical');" 2>/dev/null + 'PHP syntax error in $BAD_ESCAPED. Commit $AFTER was reverted and force-pushed to GitHub.','critical');" 2>/dev/null log "Reverted. Bad commit: $AFTER" continue fi diff --git a/deploy/jarvis-watchdog.sh b/deploy/jarvis-watchdog.sh index 2a21a7e..c227af9 100755 --- a/deploy/jarvis-watchdog.sh +++ b/deploy/jarvis-watchdog.sh @@ -10,15 +10,23 @@ TS() { date '+%Y-%m-%d %H:%M:%S'; } log() { echo "[$(TS)] $1" >> "$LOG"; } +# Escape single quotes for MySQL string interpolation in bash +sql_esc() { printf '%s' "$1" | sed "s/'/\\\\''/g"; } + alert() { local type="$1" title="$2" msg="$3" sev="${4:-warning}" + local e_type e_title e_msg e_sev + e_type=$(sql_esc "$type"); e_title=$(sql_esc "$title") + e_msg=$(sql_esc "$msg"); e_sev=$(sql_esc "$sev") $MYSQL "INSERT IGNORE INTO alerts (alert_type,title,message,severity,source_key,auto_resolve) - VALUES ('$type','$title','$msg','$sev','watchdog:$type',1);" 2>/dev/null + VALUES ('$e_type','$e_title','$e_msg','$e_sev','watchdog:$e_type',1);" 2>/dev/null } resolve() { + local e_key + e_key=$(sql_esc "$1") $MYSQL "UPDATE alerts SET resolved=1,resolved_at=NOW() - WHERE source_key='watchdog:$1' AND resolved=0;" 2>/dev/null + WHERE source_key='watchdog:$e_key' AND resolved=0;" 2>/dev/null } # ── Service health ───────────────────────────────────────────────────────────── diff --git a/public_html/agent/jarvis-agent.py b/public_html/agent/jarvis-agent.py index 8045fc2..b74bf63 100644 --- a/public_html/agent/jarvis-agent.py +++ b/public_html/agent/jarvis-agent.py @@ -331,9 +331,9 @@ def execute_command(cmd: dict) -> dict: return {"success": True, "updated": updated} elif cmd_type == "shell": - # Only allow if explicitly enabled in config - if not cmd_data.get("allowed", False): - return {"success": False, "error": "Shell commands not enabled"} + # Guard reads LOCAL config, not the server-supplied payload + if not cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} cmd_str = cmd_data.get("command", "") r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} @@ -359,15 +359,13 @@ def main(): poll_interval = int(cfg.get("poll_interval", 30)) heartbeat_every = int(cfg.get("heartbeat_every", 10)) - # Register if no API key yet + # Register if no API key yet — loop (not recurse) to avoid stack overflow api_key = state.get("api_key", "") - if not api_key: + while not api_key: api_key = register(cfg, state) if not api_key: print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) time.sleep(60) - main() - return headers = {"X-Agent-Key": api_key} last_metrics = 0 @@ -424,7 +422,9 @@ def main(): # ── Self-update ──────────────────────────────────────────────────────────────── def self_update(cfg: dict) -> bool: - """Check JARVIS server for a newer version of this script. If different, replace and restart.""" + """Check JARVIS server for a newer version of this script. + Verifies SHA-256 hash from .sha256 before replacing.""" + import hashlib jarvis_url = cfg.get("jarvis_url", "").rstrip("/") default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" update_url = cfg.get("update_url", default_update_url) @@ -432,14 +432,37 @@ def self_update(cfg: dict) -> bool: return False script_path = os.path.abspath(__file__) try: + # Download expected hash first + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req_hash.add_header("Host", _host_header) + try: + with urllib.request.urlopen(req_hash, timeout=10) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + expected_hash = None + + # Download new script req = urllib.request.Request(update_url) req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) with urllib.request.urlopen(req, timeout=30) as resp: new_content = resp.read() + + # Verify hash if available — abort if mismatch + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + print(f"[JARVIS] Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting", flush=True) + return False + with open(script_path, "rb") as f: current = f.read() if new_content != current: - print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) + print(f"[JARVIS] Update verified — replacing {script_path} and restarting...", flush=True) with open(script_path, "wb") as f: f.write(new_content) os.execv(sys.executable, [sys.executable] + sys.argv) diff --git a/public_html/agent/jarvis-agent.py.sha256 b/public_html/agent/jarvis-agent.py.sha256 new file mode 100644 index 0000000..0859cee --- /dev/null +++ b/public_html/agent/jarvis-agent.py.sha256 @@ -0,0 +1 @@ +6c93ea50f3a91472444a10838b89d4222b8378cd153efee4ed9f75d7d5fb25b2 diff --git a/public_html/webhook.php b/public_html/webhook.php index 574658a..fd0e862 100644 --- a/public_html/webhook.php +++ b/public_html/webhook.php @@ -3,9 +3,16 @@ * GitHub Auto-Deploy Webhook * Verifies GitHub HMAC signature, then queues the repo for git pull. * A root cron job (/usr/local/bin/jarvis-deploy.sh) processes the queue every minute. + * + * WEBHOOK_SECRET is loaded from api/config.php (gitignored) — never hardcoded here. */ -define('WEBHOOK_SECRET', '8a8c50c83d37527bdef876f1736b654235724a1a475cb8e5'); +require_once __DIR__ . '/../../api/config.php'; +if (!defined('WEBHOOK_SECRET')) { + http_response_code(500); + echo json_encode(['error' => 'Webhook not configured']); + exit; +} define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt'); define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');