loki-mode 6.36.6 → 6.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.36.6
6
+ # Loki Mode v6.37.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.36.6 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.37.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.36.6
1
+ 6.37.1
package/autonomy/loki CHANGED
@@ -2635,6 +2635,21 @@ cmd_dashboard_start() {
2635
2635
  echo "$host" > "${DASHBOARD_PID_DIR}/host"
2636
2636
  echo "$url_scheme" > "${DASHBOARD_PID_DIR}/scheme"
2637
2637
 
2638
+ # Wait for PID file to be written (up to 10 seconds)
2639
+ # Fixes race condition where PID file may not exist yet when checked
2640
+ local pid_wait_retries=20
2641
+ local pid_wait_interval=0.5
2642
+ while [[ $pid_wait_retries -gt 0 ]]; do
2643
+ if [[ -f "$DASHBOARD_PID_FILE" ]]; then
2644
+ break
2645
+ fi
2646
+ sleep "$pid_wait_interval"
2647
+ pid_wait_retries=$((pid_wait_retries - 1))
2648
+ done
2649
+ if [[ ! -f "$DASHBOARD_PID_FILE" ]]; then
2650
+ echo -e "${YELLOW}Warning: Dashboard PID file not written within timeout${NC}"
2651
+ fi
2652
+
2638
2653
  # Wait a moment and check if process is still running
2639
2654
  sleep 1
2640
2655
 
@@ -3002,6 +3017,16 @@ cmd_web_start() {
3002
3017
  esac
3003
3018
  done
3004
3019
 
3020
+ # Validate port: must be numeric and in valid range
3021
+ if [[ ! "$port" =~ ^[0-9]+$ ]]; then
3022
+ echo -e "${RED}Error: Port must be a number, got '$port'${NC}"
3023
+ exit 1
3024
+ fi
3025
+ if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
3026
+ echo -e "${RED}Error: Port must be between 1 and 65535, got $port${NC}"
3027
+ exit 1
3028
+ fi
3029
+
3005
3030
  # Validate --prd file if provided
3006
3031
  if [ -n "$prd_file" ]; then
3007
3032
  if [ ! -f "$prd_file" ]; then
@@ -4624,6 +4649,7 @@ cmd_export() {
4624
4649
  exit 0
4625
4650
  ;;
4626
4651
  json)
4652
+ require_jq || return 1
4627
4653
  _export_json "$output_path"
4628
4654
  ;;
4629
4655
  markdown|md)
@@ -4633,6 +4659,7 @@ cmd_export() {
4633
4659
  _export_csv "$output_path"
4634
4660
  ;;
4635
4661
  timeline)
4662
+ require_jq || return 1
4636
4663
  _export_timeline "$output_path"
4637
4664
  ;;
4638
4665
  *)
package/autonomy/run.sh CHANGED
@@ -5887,14 +5887,20 @@ run_adversarial_testing() {
5887
5887
  echo "$diff_content" > "$diff_file"
5888
5888
  echo "$changed_files" > "$files_file"
5889
5889
 
5890
- # Build adversarial prompt
5891
- local adversarial_prompt="You are an ADVERSARIAL TESTER. Your goal is to BREAK the implementation.
5890
+ # Build adversarial prompt -- use heredoc with quoted delimiter to prevent
5891
+ # shell variable expansion in diff content (fixes #78)
5892
+ local files_content changed_content
5893
+ files_content=$(cat "$files_file")
5894
+ changed_content=$(head -500 "$diff_file")
5895
+ local adversarial_prompt
5896
+ read -r -d '' adversarial_prompt <<'ADVERSARIAL_EOF' || true
5897
+ You are an ADVERSARIAL TESTER. Your goal is to BREAK the implementation.
5892
5898
 
5893
5899
  CHANGED FILES:
5894
- $(cat "$files_file")
5900
+ __FILES_PLACEHOLDER__
5895
5901
 
5896
5902
  DIFF:
5897
- $(head -500 "$diff_file")
5903
+ __DIFF_PLACEHOLDER__
5898
5904
 
5899
5905
  YOUR MISSION:
5900
5906
  1. Find edge cases that will cause crashes or incorrect behavior
@@ -5913,7 +5919,11 @@ ATTACK_VECTORS:
5913
5919
  SUGGESTED_TESTS:
5914
5920
  - Test description that would catch this issue
5915
5921
 
5916
- OVERALL_RISK: HIGH or MEDIUM or LOW"
5922
+ OVERALL_RISK: HIGH or MEDIUM or LOW
5923
+ ADVERSARIAL_EOF
5924
+ # Substitute placeholders with actual content (safe from shell expansion)
5925
+ adversarial_prompt="${adversarial_prompt/__FILES_PLACEHOLDER__/$files_content}"
5926
+ adversarial_prompt="${adversarial_prompt/__DIFF_PLACEHOLDER__/$changed_content}"
5917
5927
 
5918
5928
  local result_file="$adversarial_dir/$test_id/result.txt"
5919
5929
 
@@ -8862,7 +8872,8 @@ if __name__ == "__main__":
8862
8872
  # Uses positional prompt after exec subcommand
8863
8873
  # Note: Effort is set via env var, not CLI flag
8864
8874
  # Uses dynamic tier from RARV phase (tier_param already set above)
8865
- { CODEX_MODEL_REASONING_EFFORT="$tier_param" \
8875
+ { LOKI_CODEX_REASONING_EFFORT="$tier_param" \
8876
+ CODEX_MODEL_REASONING_EFFORT="$tier_param" \
8866
8877
  codex exec --full-auto \
8867
8878
  "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \
8868
8879
  } && exit_code=0 || exit_code=$?
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.36.6"
10
+ __version__ = "6.37.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
package/dashboard/auth.py CHANGED
@@ -468,17 +468,69 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
468
468
  - Audience matches OIDC_AUDIENCE or OIDC_CLIENT_ID
469
469
  - Token is not expired
470
470
 
471
- SECURITY WARNING: Cryptographic signature verification is NOT performed
472
- unless PyJWT is installed. This implementation only validates claims.
473
- An attacker can forge JWTs unless LOKI_OIDC_SKIP_SIGNATURE_VERIFY is
474
- explicitly set to 'true' (which is INSECURE and only for local testing).
475
-
476
- For production: Install PyJWT + cryptography for proper RSA/HMAC
477
- signature verification.
471
+ SECURITY CRITICAL: Without PyJWT, JWT signatures are NOT cryptographically
472
+ verified. An attacker can forge tokens with arbitrary claims. For any
473
+ production deployment, you MUST install PyJWT + cryptography so that
474
+ this function verifies the RS256/RS384/RS512 signature against the
475
+ provider's JWKS endpoint. Without signature verification, OIDC
476
+ authentication provides ZERO security.
477
+
478
+ For production: pip install PyJWT cryptography
478
479
  """
479
480
  if not OIDC_ENABLED:
480
481
  return None
481
482
 
483
+ import logging as _logging
484
+ import sys
485
+ _auth_logger = _logging.getLogger("loki.auth")
486
+
487
+ # -- Attempt PyJWT-based cryptographic verification first --
488
+ # This is the ONLY secure path. Without this, tokens are NOT verified.
489
+ try:
490
+ import jwt as _pyjwt
491
+ from jwt import PyJWKClient
492
+
493
+ jwks_config = _get_oidc_config()
494
+ jwks_uri = jwks_config.get("jwks_uri")
495
+ if not jwks_uri:
496
+ _auth_logger.error("OIDC discovery document missing jwks_uri -- cannot verify token")
497
+ return None
498
+
499
+ jwks_client = PyJWKClient(jwks_uri)
500
+ signing_key = jwks_client.get_signing_key_from_jwt(token_str)
501
+
502
+ expected_aud = OIDC_AUDIENCE or OIDC_CLIENT_ID
503
+ decoded = _pyjwt.decode(
504
+ token_str,
505
+ signing_key.key,
506
+ algorithms=["RS256", "RS384", "RS512"],
507
+ audience=expected_aud,
508
+ issuer=OIDC_ISSUER,
509
+ )
510
+
511
+ return {
512
+ "id": decoded.get("sub", ""),
513
+ "name": decoded.get("name", decoded.get("email", decoded.get("sub", ""))),
514
+ "email": decoded.get("email", ""),
515
+ "scopes": ["*"], # OIDC users get full access
516
+ "auth_method": "oidc",
517
+ "issuer": decoded.get("iss"),
518
+ }
519
+ except ImportError:
520
+ # PyJWT not installed -- fall through to claims-only path with loud warning
521
+ _warning_msg = (
522
+ "WARNING: OIDC JWT signatures are NOT cryptographically verified. "
523
+ "Install PyJWT with 'pip install PyJWT cryptography' for production use."
524
+ )
525
+ _auth_logger.warning(_warning_msg)
526
+ # Also print to stderr so operators notice even without log config
527
+ print(_warning_msg, file=sys.stderr)
528
+ except Exception as exc:
529
+ _auth_logger.error("PyJWT signature verification failed: %s", exc)
530
+ return None
531
+
532
+ # -- Fallback: claims-only validation (INSECURE without PyJWT) --
533
+
482
534
  try:
483
535
  parts = token_str.split(".")
484
536
  if len(parts) != 3:
@@ -487,16 +539,14 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
487
539
  # Basic sanity check: signature part must not be empty
488
540
  header_b64, payload_b64, signature_b64 = parts
489
541
  if not signature_b64 or len(signature_b64) < 10:
490
- import logging as _logging
491
- _logging.getLogger("loki.auth").error(
542
+ _auth_logger.error(
492
543
  "OIDC token rejected: signature part is missing or too short"
493
544
  )
494
545
  return None
495
546
 
496
547
  # CRITICAL: Check if signature verification is explicitly skipped
497
548
  if not OIDC_SKIP_SIGNATURE_VERIFY:
498
- import logging as _logging
499
- _logging.getLogger("loki.auth").critical(
549
+ _auth_logger.critical(
500
550
  "OIDC token received but signature verification is NOT implemented. "
501
551
  "Set LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true to explicitly allow "
502
552
  "unverified tokens (INSECURE - local testing only), or install "
@@ -266,7 +266,8 @@ def get_status() -> StatusResponse:
266
266
  running = True
267
267
  else:
268
268
  running = True
269
- except (json.JSONDecodeError, KeyError):
269
+ except (json.JSONDecodeError, OSError, KeyError):
270
+ # Partial JSON from concurrent write -- treat as unavailable
270
271
  pass
271
272
 
272
273
  # Determine state
@@ -304,7 +305,8 @@ def get_status() -> StatusResponse:
304
305
  pending_tasks = len(pending)
305
306
  elif isinstance(pending, dict):
306
307
  pending_tasks = len(pending.get("tasks", []))
307
- except (json.JSONDecodeError, KeyError):
308
+ except (json.JSONDecodeError, OSError, KeyError):
309
+ # Partial JSON from concurrent write -- treat as unavailable
308
310
  pass
309
311
 
310
312
  # Read provider from state
@@ -318,7 +320,8 @@ def get_status() -> StatusResponse:
318
320
  try:
319
321
  orch = json.loads(orch_file.read_text())
320
322
  current_task = orch.get("currentTask", "")
321
- except (json.JSONDecodeError, KeyError):
323
+ except (json.JSONDecodeError, OSError, KeyError):
324
+ # Partial JSON from concurrent write -- treat as unavailable
322
325
  pass
323
326
 
324
327
  return StatusResponse(
@@ -458,7 +461,7 @@ async def stop_session():
458
461
 
459
462
  # Atomic write with file locking to prevent race conditions
460
463
  atomic_write_json(session_file, session_data, use_lock=True)
461
- except (json.JSONDecodeError, KeyError, RuntimeError):
464
+ except (json.JSONDecodeError, OSError, KeyError, RuntimeError):
462
465
  pass
463
466
 
464
467
  # Emit stop event
@@ -263,7 +263,7 @@ class ConnectionManager:
263
263
  async def broadcast(self, message: dict[str, Any]) -> None:
264
264
  """Broadcast a message to all connected clients."""
265
265
  disconnected = []
266
- for connection in self.active_connections:
266
+ for connection in list(self.active_connections):
267
267
  try:
268
268
  await connection.send_json(message)
269
269
  except Exception as e:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.36.6
5
+ **Version:** v6.37.1
6
6
 
7
7
  ---
8
8
 
package/events/bus.py CHANGED
@@ -139,9 +139,10 @@ class EventBus:
139
139
  """Save event ID as processed."""
140
140
  self._processed_ids.add(event_id)
141
141
 
142
- # Prune to last 1000
142
+ # Prune to last 1000 (always, regardless of disk write outcome)
143
143
  if len(self._processed_ids) > 1000:
144
- self._processed_ids = set(list(self._processed_ids)[-1000:])
144
+ pruned = list(self._processed_ids)[-1000:]
145
+ self._processed_ids = set(pruned)
145
146
 
146
147
  try:
147
148
  with open(self.processed_file, 'w') as f:
@@ -151,6 +152,7 @@ class EventBus:
151
152
  finally:
152
153
  fcntl.flock(f.fileno(), fcntl.LOCK_UN)
153
154
  except IOError:
155
+ # Disk write failed -- set is already pruned in memory above
154
156
  pass
155
157
 
156
158
  def emit(self, event: LokiEvent) -> str:
@@ -338,6 +340,24 @@ class EventBus:
338
340
  self._thread.join(timeout=2.0)
339
341
  self._thread = None
340
342
 
343
+ def close(self) -> None:
344
+ """Clean up all threading resources."""
345
+ self.stop_background_processing()
346
+ self._subscribers.clear()
347
+
348
+ def __del__(self) -> None:
349
+ """Ensure threads are cleaned up on garbage collection."""
350
+ try:
351
+ self.close()
352
+ except Exception:
353
+ pass
354
+
355
+ def __enter__(self) -> 'EventBus':
356
+ return self
357
+
358
+ def __exit__(self, *exc) -> None:
359
+ self.close()
360
+
341
361
  def get_event_history(
342
362
  self,
343
363
  types: Optional[List[EventType]] = None,
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.36.6'
60
+ __version__ = '6.37.1'
package/mcp/server.py CHANGED
@@ -143,11 +143,31 @@ def validate_path(path: str, allowed_dirs: List[str] = None) -> str:
143
143
 
144
144
  project_root = get_project_root()
145
145
 
146
- # Resolve to absolute path, following symlinks
146
+ # Build absolute path without resolving symlinks first
147
147
  if os.path.isabs(path):
148
- resolved_path = os.path.realpath(path)
148
+ abs_path = path
149
149
  else:
150
- resolved_path = os.path.realpath(os.path.join(project_root, path))
150
+ abs_path = os.path.join(project_root, path)
151
+
152
+ # Walk each component to detect symlink chains escaping allowed dirs
153
+ # This prevents symlinks that hop through directories outside the project
154
+ parts = os.path.normpath(abs_path).split(os.sep)
155
+ current = os.sep if abs_path.startswith(os.sep) else ''
156
+ for part in parts:
157
+ if not part:
158
+ continue
159
+ current = os.path.join(current, part)
160
+ if os.path.islink(current):
161
+ link_target = os.path.realpath(current)
162
+ # Each symlink target must resolve within the project root
163
+ if not link_target.startswith(project_root + os.sep) and link_target != project_root:
164
+ raise PathTraversalError(
165
+ f"Access denied: Symlink '{current}' escapes project root "
166
+ f"(target: '{link_target}')"
167
+ )
168
+
169
+ # Resolve to absolute path, following all symlinks for final check
170
+ resolved_path = os.path.realpath(abs_path)
151
171
 
152
172
  # Check if path is within any of the allowed directories
153
173
  for allowed_dir in allowed_dirs:
@@ -1022,11 +1042,11 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
1022
1042
  try:
1023
1043
  content = prd_content
1024
1044
  if not content and prd_path:
1025
- # Resolve relative paths against project root, absolute paths used as-is
1026
- if os.path.isabs(prd_path):
1027
- resolved = os.path.realpath(prd_path)
1028
- else:
1029
- resolved = os.path.realpath(os.path.join(get_project_root(), prd_path))
1045
+ # Resolve symlinks and validate path is within project root
1046
+ try:
1047
+ resolved = validate_path(prd_path, allowed_dirs=['.'])
1048
+ except PathTraversalError as e:
1049
+ return json.dumps({"error": str(e)})
1030
1050
  if os.path.exists(resolved) and os.path.isfile(resolved):
1031
1051
  with open(resolved, 'r', encoding='utf-8') as f:
1032
1052
  content = f.read()
@@ -458,9 +458,21 @@ class NamespaceManager:
458
458
 
459
459
  Returns:
460
460
  Path to the namespace's storage directory
461
+
462
+ Raises:
463
+ ValueError: If namespace contains path traversal characters
461
464
  """
462
465
  if namespace == DEFAULT_NAMESPACE:
463
466
  return self.base_path
467
+ # Block path traversal -- only allow alphanumeric, hyphen, underscore
468
+ if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
469
+ raise ValueError(
470
+ f"Invalid namespace '{namespace}': "
471
+ "only alphanumeric characters, hyphens, and underscores are allowed"
472
+ )
473
+ resolved = (self.base_path / namespace).resolve()
474
+ if not str(resolved).startswith(str(self.base_path.resolve())):
475
+ raise ValueError(f"Namespace '{namespace}' resolves outside base path")
464
476
  return self.base_path / namespace
465
477
 
466
478
  def ensure_namespace_exists(self, namespace: str) -> Path:
package/memory/storage.py CHANGED
@@ -66,6 +66,15 @@ class MemoryStorage:
66
66
  self._root_path = Path(base_path)
67
67
  self._namespace = namespace
68
68
 
69
+ # Validate namespace to prevent path traversal
70
+ if namespace and namespace != DEFAULT_NAMESPACE:
71
+ import re
72
+ if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
73
+ raise ValueError(
74
+ f"Invalid namespace '{namespace}': "
75
+ "only alphanumeric characters, hyphens, and underscores are allowed"
76
+ )
77
+
69
78
  # Calculate effective base path (with namespace if specified)
70
79
  if namespace and namespace != DEFAULT_NAMESPACE:
71
80
  self.base_path = self._root_path / namespace
@@ -97,7 +106,17 @@ class MemoryStorage:
97
106
 
98
107
  Returns:
99
108
  New MemoryStorage instance for the specified namespace
109
+
110
+ Raises:
111
+ ValueError: If namespace contains path traversal characters
100
112
  """
113
+ import re
114
+ if namespace and namespace != DEFAULT_NAMESPACE:
115
+ if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
116
+ raise ValueError(
117
+ f"Invalid namespace '{namespace}': "
118
+ "only alphanumeric characters, hyphens, and underscores are allowed"
119
+ )
101
120
  return MemoryStorage(
102
121
  base_path=str(self._root_path),
103
122
  namespace=namespace,
@@ -122,13 +141,21 @@ class MemoryStorage:
122
141
  self._cleanup_stale_locks()
123
142
 
124
143
  def _cleanup_stale_locks(self) -> None:
125
- """Remove stale .lock files older than 5 minutes (safe with concurrent processes)."""
144
+ """Remove stale .lock files older than 5 minutes (safe with concurrent processes).
145
+
146
+ Uses file age in seconds (monotonic comparison) instead of wall-clock
147
+ datetime comparison, which breaks when the system clock jumps.
148
+ """
126
149
  try:
127
- stale_cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
150
+ import time
151
+ now_mono = time.monotonic()
152
+ now_real = time.time()
153
+ stale_seconds = 300 # 5 minutes
128
154
  for lock_file in self.base_path.rglob("*.lock"):
129
155
  try:
130
- mtime = datetime.fromtimestamp(lock_file.stat().st_mtime, tz=timezone.utc)
131
- if mtime < stale_cutoff:
156
+ file_mtime = lock_file.stat().st_mtime
157
+ age_seconds = now_real - file_mtime
158
+ if age_seconds > stale_seconds:
132
159
  lock_file.unlink()
133
160
  except OSError:
134
161
  pass
@@ -19,8 +19,11 @@ import os
19
19
  from dataclasses import dataclass, field, asdict
20
20
  from datetime import datetime, timezone
21
21
  from pathlib import Path
22
+ import logging
22
23
  from typing import Any, Dict, List, Optional
23
24
 
25
+ logger = logging.getLogger(__name__)
26
+
24
27
 
25
28
  # -----------------------------------------------------------------------------
26
29
  # Constants
@@ -367,6 +370,7 @@ def evaluate_thresholds(metrics: dict) -> List[Action]:
367
370
 
368
371
  metric_value = metrics.get(metric_name)
369
372
  if metric_value is None:
373
+ logger.warning("Threshold metric '%s' not found in metrics; skipping evaluation", metric_name)
370
374
  continue
371
375
 
372
376
  triggered = False
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.36.6",
3
+ "version": "6.37.1",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
@@ -153,11 +153,15 @@ resolve_model_for_tier() {
153
153
 
154
154
  # Tier-aware invocation
155
155
  # Codex CLI uses CODEX_MODEL_REASONING_EFFORT env var for effort control
156
+ # LOKI_CODEX_REASONING_EFFORT is the canonical namespaced env var (v6.37.1+)
157
+ # CODEX_MODEL_REASONING_EFFORT is supported for backward compatibility (deprecated)
156
158
  provider_invoke_with_tier() {
157
159
  local tier="$1"
158
160
  local prompt="$2"
159
161
  shift 2
160
162
  local effort
161
163
  effort=$(resolve_model_for_tier "$tier")
162
- CODEX_MODEL_REASONING_EFFORT="$effort" codex exec --full-auto "$prompt" "$@"
164
+ LOKI_CODEX_REASONING_EFFORT="$effort" \
165
+ CODEX_MODEL_REASONING_EFFORT="$effort" \
166
+ codex exec --full-auto "$prompt" "$@"
163
167
  }
package/web-app/server.py CHANGED
@@ -316,7 +316,8 @@ async def start_session(req: StartRequest) -> JSONResponse:
316
316
  text=True,
317
317
  cwd=project_dir,
318
318
  env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
319
- start_new_session=True, # create new process group for clean kill
319
+ **({"start_new_session": True} if sys.platform != "win32"
320
+ else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
320
321
  )
321
322
  except FileNotFoundError:
322
323
  return JSONResponse(
@@ -371,23 +372,37 @@ async def stop_session() -> JSONResponse:
371
372
  await session.cleanup()
372
373
 
373
374
  # 3. Kill the process group (catches child processes too)
374
- try:
375
- pgid = os.getpgid(proc.pid)
376
- os.killpg(pgid, signal.SIGTERM)
377
- except (ProcessLookupError, PermissionError, OSError):
378
- # Fallback: kill the process directly
375
+ if sys.platform != "win32":
376
+ try:
377
+ pgid = os.getpgid(proc.pid)
378
+ os.killpg(pgid, signal.SIGTERM)
379
+ except (ProcessLookupError, PermissionError, OSError):
380
+ try:
381
+ proc.terminate()
382
+ except Exception:
383
+ pass
384
+ else:
379
385
  try:
380
- proc.terminate()
386
+ subprocess.call(["taskkill", "/F", "/T", "/PID", str(proc.pid)])
381
387
  except Exception:
382
- pass
388
+ try:
389
+ proc.terminate()
390
+ except Exception:
391
+ pass
383
392
 
384
393
  try:
385
394
  proc.wait(timeout=5)
386
395
  except subprocess.TimeoutExpired:
387
- try:
388
- pgid = os.getpgid(proc.pid)
389
- os.killpg(pgid, signal.SIGKILL)
390
- except (ProcessLookupError, PermissionError, OSError):
396
+ if sys.platform != "win32":
397
+ try:
398
+ pgid = os.getpgid(proc.pid)
399
+ os.killpg(pgid, signal.SIGKILL)
400
+ except (ProcessLookupError, PermissionError, OSError):
401
+ try:
402
+ proc.kill()
403
+ except Exception:
404
+ pass
405
+ else:
391
406
  try:
392
407
  proc.kill()
393
408
  except Exception:
@@ -966,7 +981,7 @@ async def get_session_detail(session_id: str) -> JSONResponse:
966
981
  """Get details of a past session for read-only viewing."""
967
982
  import re
968
983
  # Validate session_id format (prevent path traversal)
969
- if not re.match(r"^[a-zA-Z0-9_-]+$", session_id):
984
+ if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
970
985
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
971
986
 
972
987
  search_dirs = [
@@ -1032,7 +1047,9 @@ async def onboard_session(req: OnboardRequest) -> JSONResponse:
1032
1047
  except (ValueError, OSError):
1033
1048
  return JSONResponse(status_code=400, content={"error": "Invalid path"})
1034
1049
  home = Path.home().resolve()
1035
- if not str(target).startswith(str(home)):
1050
+ try:
1051
+ target.relative_to(home)
1052
+ except ValueError:
1036
1053
  return JSONResponse(status_code=400, content={"error": "Path must be within your home directory"})
1037
1054
  if not target.exists():
1038
1055
  return JSONResponse(status_code=400, content={"error": "Path does not exist"})