loki-mode 5.46.0 → 5.47.0

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/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  [![HumanEval](https://img.shields.io/badge/HumanEval-98.17%25%20Pass%401-brightgreen)](benchmarks/results/)
14
14
  [![SWE-bench](https://img.shields.io/badge/SWE--bench-99.67%25%20Patch%20Gen-brightgreen)](benchmarks/results/)
15
15
 
16
- **Current Version: v5.46.0**
16
+ **Current Version: v5.47.0**
17
17
 
18
18
  **[Autonomi](https://www.autonomi.dev/)** | **[Documentation](https://www.autonomi.dev/docs)** | **[GitHub](https://github.com/asklokesh/loki-mode)**
19
19
 
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 zero human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v5.46.0
6
+ # Loki Mode v5.47.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -262,4 +262,4 @@ The following features are documented in skill modules but not yet fully automat
262
262
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
263
263
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
264
264
 
265
- **v5.46.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
265
+ **v5.47.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.46.0
1
+ 5.47.0
@@ -395,8 +395,9 @@ _install_node_deps() {
395
395
  _install_python_deps() {
396
396
  local dir="$1"
397
397
  if [ -f "$dir/requirements.txt" ]; then
398
- log_step "App Runner: installing Python dependencies (background)..."
399
- (cd "$dir" && pip install -r requirements.txt >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
398
+ log_step "App Runner: installing Python dependencies..."
399
+ (cd "$dir" && pip install -r requirements.txt >> "$_APP_RUNNER_DIR/app.log" 2>&1) || \
400
+ log_warn "App Runner: pip install failed, app may not start"
400
401
  fi
401
402
  }
402
403
 
@@ -425,14 +426,18 @@ app_runner_start() {
425
426
  _rotate_app_log
426
427
 
427
428
  # Start the process in a new process group
428
- (cd "$dir" && setsid bash -c "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
429
+ if command -v setsid >/dev/null 2>&1; then
430
+ (cd "$dir" && setsid bash -c "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
431
+ else
432
+ (cd "$dir" && bash -c "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
433
+ fi
429
434
  _APP_RUNNER_PID=$!
430
435
 
431
436
  # Write PID file
432
437
  echo "$_APP_RUNNER_PID" > "$_APP_RUNNER_DIR/app.pid"
433
438
 
434
439
  # Capture initial git diff hash for change detection
435
- _GIT_DIFF_HASH=$(cd "$dir" && git diff --stat 2>/dev/null | md5sum 2>/dev/null | awk '{print $1}' || echo "none")
440
+ _GIT_DIFF_HASH=$(cd "$dir" && git diff --stat 2>/dev/null | (md5sum 2>/dev/null || md5 -r 2>/dev/null) | awk '{print $1}' || echo "none")
436
441
 
437
442
  # Brief pause for process to initialize
438
443
  sleep 2
@@ -557,7 +562,7 @@ app_runner_should_restart() {
557
562
 
558
563
  # Get current git diff hash
559
564
  local current_hash
560
- current_hash=$(cd "$dir" && git diff --stat 2>/dev/null | md5sum 2>/dev/null | awk '{print $1}' || echo "none")
565
+ current_hash=$(cd "$dir" && git diff --stat 2>/dev/null | (md5sum 2>/dev/null || md5 -r 2>/dev/null) | awk '{print $1}' || echo "none")
561
566
 
562
567
  # No change
563
568
  if [ "$current_hash" = "$_GIT_DIFF_HASH" ]; then
@@ -631,7 +636,7 @@ app_runner_watchdog() {
631
636
  # Clear PID and restart
632
637
  rm -f "$_APP_RUNNER_DIR/app.pid"
633
638
  _APP_RUNNER_PID=""
634
- app_runner_start
639
+ app_runner_start || log_warn "App Runner: auto-restart failed"
635
640
  }
636
641
 
637
642
  #===============================================================================
@@ -32,7 +32,7 @@ from pathlib import Path
32
32
 
33
33
  # Allowed characters in check paths and patterns (security: prevent injection)
34
34
  _SAFE_PATH_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?]+$')
35
- _SAFE_PATTERN_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?|\\()+^$\s]+$')
35
+ _SAFE_PATTERN_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?|\\()+^$\s:=<>@#"\'`,;!&%]+$')
36
36
 
37
37
 
38
38
  def _validate_path(path: str, project_dir: str) -> str:
@@ -170,7 +170,8 @@ def run_check(check: dict, project_dir: str, timeout: int) -> dict:
170
170
  elif check_type == "http_check":
171
171
  path = check.get("path", "/")
172
172
  # Validate path is safe
173
- if path and not _SAFE_PATH_RE.match(path.lstrip("/")):
173
+ stripped = path.lstrip("/")
174
+ if stripped and not _SAFE_PATH_RE.match(stripped):
174
175
  result["passed"] = None
175
176
  result["output"] = f"Unsafe path rejected: {path!r}"
176
177
  else:
@@ -461,6 +461,124 @@ try:
461
461
  except: print('Results unavailable')
462
462
  " >> "$evidence_file" 2>/dev/null || echo "Playwright data unavailable" >> "$evidence_file"
463
463
  fi
464
+
465
+ # Add hard gate status
466
+ if [ -f "$COUNCIL_STATE_DIR/gate-block.json" ]; then
467
+ echo "" >> "$evidence_file"
468
+ echo "## Hard Gate Status: BLOCKED" >> "$evidence_file"
469
+ echo "Critical checklist items are failing. Completion is blocked until resolved." >> "$evidence_file"
470
+ cat "$COUNCIL_STATE_DIR/gate-block.json" >> "$evidence_file"
471
+ fi
472
+ }
473
+
474
+ #===============================================================================
475
+ # Council Reverify Checklist - Re-run checklist before evaluation
476
+ #===============================================================================
477
+
478
+ # Re-verify checklist before council evaluation to ensure fresh data
479
+ council_reverify_checklist() {
480
+ if type checklist_verify &>/dev/null && [ -f ".loki/checklist/checklist.json" ]; then
481
+ log_info "[Council] Re-verifying checklist before evaluation..."
482
+ checklist_verify 2>/dev/null || true
483
+ fi
484
+ }
485
+
486
+ #===============================================================================
487
+ # Council Checklist Hard Gate - Block completion on critical failures
488
+ #===============================================================================
489
+
490
+ # Council hard gate: blocks completion if critical checklist items are failing
491
+ # Returns 0 if gate passes (ok to complete), 1 if gate blocks (critical failures exist)
492
+ council_checklist_gate() {
493
+ local results_file=".loki/checklist/verification-results.json"
494
+ local waivers_file=".loki/checklist/waivers.json"
495
+
496
+ # No checklist = no gate (backwards compatible)
497
+ if [ ! -f "$results_file" ]; then
498
+ return 0
499
+ fi
500
+
501
+ # Check for critical failures, excluding waived items
502
+ local gate_result
503
+ gate_result=$(_RESULTS_FILE="$results_file" _WAIVERS_FILE="$waivers_file" python3 -c "
504
+ import json, sys, os
505
+
506
+ results_file = os.environ['_RESULTS_FILE']
507
+ waivers_file = os.environ.get('_WAIVERS_FILE', '')
508
+
509
+ try:
510
+ with open(results_file) as f:
511
+ results = json.load(f)
512
+ except (json.JSONDecodeError, IOError, KeyError):
513
+ print('PASS')
514
+ sys.exit(0)
515
+
516
+ # Load waivers
517
+ waived_ids = set()
518
+ if waivers_file and os.path.exists(waivers_file):
519
+ try:
520
+ with open(waivers_file) as f:
521
+ waivers = json.load(f)
522
+ waived_ids = {w['item_id'] for w in waivers.get('waivers', []) if w.get('active', True)}
523
+ except (json.JSONDecodeError, KeyError):
524
+ pass
525
+
526
+ # Find critical failures not waived
527
+ critical_failures = []
528
+ for cat in results.get('categories', []):
529
+ for item in cat.get('items', []):
530
+ if item.get('priority') == 'critical' and item.get('status') == 'failing':
531
+ if item.get('id') not in waived_ids:
532
+ critical_failures.append(item.get('title', item.get('id', 'unknown')))
533
+
534
+ if critical_failures:
535
+ print('BLOCK:' + '|'.join(critical_failures[:5]))
536
+ sys.exit(0)
537
+ else:
538
+ print('PASS')
539
+ sys.exit(0)
540
+ " 2>/dev/null || echo "PASS")
541
+
542
+ if [[ "$gate_result" == BLOCK:* ]]; then
543
+ local failures="${gate_result#BLOCK:}"
544
+ log_warn "[Council] Hard gate BLOCKED: critical checklist failures: ${failures//|/, }"
545
+
546
+ # Write gate block to council state (atomic write via temp file)
547
+ local gate_file="$COUNCIL_STATE_DIR/gate-block.json"
548
+ local gate_tmp="${gate_file}.tmp"
549
+ local timestamp
550
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
551
+ local failures_json
552
+ failures_json=$(_FAILURES="$failures" python3 -c "
553
+ import json, os
554
+ items = os.environ['_FAILURES'].split('|')
555
+ print(json.dumps(items))
556
+ " 2>/dev/null || echo '[]')
557
+ local critical_count
558
+ critical_count=$(_FAILURES="$failures" python3 -c "
559
+ import os
560
+ print(len(os.environ['_FAILURES'].split('|')))
561
+ " 2>/dev/null || echo '0')
562
+ cat > "$gate_tmp" << GATE_EOF
563
+ {
564
+ "status": "blocked",
565
+ "blocked": true,
566
+ "blocked_at": "$timestamp",
567
+ "iteration": ${ITERATION_COUNT:-0},
568
+ "reason": "critical_checklist_failures",
569
+ "critical_failures": $critical_count,
570
+ "failures": $failures_json
571
+ }
572
+ GATE_EOF
573
+ mv "$gate_tmp" "$gate_file"
574
+ return 1
575
+ fi
576
+
577
+ # Gate passes
578
+ if [ -f "$COUNCIL_STATE_DIR/gate-block.json" ]; then
579
+ rm -f "$COUNCIL_STATE_DIR/gate-block.json"
580
+ fi
581
+ return 0
464
582
  }
465
583
 
466
584
  #===============================================================================
@@ -1019,6 +1137,15 @@ council_evaluate() {
1019
1137
 
1020
1138
  log_info "Running council evaluation pipeline (round $ITERATION_COUNT)..."
1021
1139
 
1140
+ # Phase 4: Re-verify checklist for fresh data
1141
+ council_reverify_checklist
1142
+
1143
+ # Phase 4: Hard gate check - block if critical checklist items failing
1144
+ if ! council_checklist_gate; then
1145
+ log_info "[Council] Completion blocked by checklist hard gate"
1146
+ return 1 # CONTINUE - can't complete with critical failures
1147
+ fi
1148
+
1022
1149
  # Step 1: Aggregate votes from all members
1023
1150
  local aggregate_result
1024
1151
  aggregate_result=$(council_aggregate_votes)
@@ -1082,8 +1209,8 @@ council_should_stop() {
1082
1209
  return 1 # Not time to check yet
1083
1210
  fi
1084
1211
 
1085
- # Run the council vote
1086
- if council_vote; then
1212
+ # Run the council evaluation (includes hard gate + aggregate votes + devil's advocate)
1213
+ if council_evaluate; then
1087
1214
  log_header "COMPLETION COUNCIL: PROJECT APPROVED"
1088
1215
  log_info "The council has determined this project is complete."
1089
1216
 
File without changes
File without changes
@@ -158,7 +158,9 @@ checklist_summary() {
158
158
  return 0
159
159
  fi
160
160
 
161
- _CHECKLIST_RESULTS="$CHECKLIST_RESULTS_FILE" python3 -c "
161
+ _CHECKLIST_RESULTS="$CHECKLIST_RESULTS_FILE" \
162
+ _CHECKLIST_WAIVERS="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json" \
163
+ python3 -c "
162
164
  import json, sys, os
163
165
  try:
164
166
  fpath = os.environ.get('_CHECKLIST_RESULTS', '')
@@ -168,18 +170,39 @@ try:
168
170
  verified = s.get('verified', 0)
169
171
  failing = s.get('failing', 0)
170
172
  pending = s.get('pending', 0)
173
+
174
+ # Load waivers
175
+ waived_ids = set()
176
+ waivers_path = os.environ.get('_CHECKLIST_WAIVERS', '')
177
+ if waivers_path and os.path.exists(waivers_path):
178
+ try:
179
+ with open(waivers_path) as wf:
180
+ wdata = json.load(wf)
181
+ for w in wdata.get('waivers', []):
182
+ if w.get('active', True):
183
+ waived_ids.add(w['item_id'])
184
+ except Exception:
185
+ pass
186
+
187
+ # Count waived items and adjust failing list
188
+ waived_count = 0
171
189
  if total == 0:
172
190
  print('')
173
191
  else:
174
192
  failing_items = []
175
193
  for cat in data.get('categories', []):
176
194
  for item in cat.get('items', []):
195
+ item_id = item.get('id', '')
196
+ if item_id in waived_ids:
197
+ waived_count += 1
198
+ continue
177
199
  if item.get('status') == 'failing' and item.get('priority') in ('critical', 'major'):
178
200
  failing_items.append(item.get('title', item.get('id', '?')))
179
201
  detail = ''
180
202
  if failing_items:
181
203
  detail = ' FAILING: ' + ', '.join(failing_items[:5])
182
- print(f'{verified}/{total} verified, {failing} failing, {pending} pending.{detail}')
204
+ waived_str = f', {waived_count} waived' if waived_count > 0 else ''
205
+ print(f'{verified}/{total} verified, {failing} failing{waived_str}, {pending} pending.{detail}')
183
206
  except Exception:
184
207
  print('', file=sys.stderr)
185
208
  " 2>/dev/null || echo ""
@@ -221,3 +244,141 @@ except Exception:
221
244
  " 2>/dev/null || echo "Checklist data unavailable"
222
245
  } >> "${evidence_file:-/dev/stdout}"
223
246
  }
247
+
248
+ #===============================================================================
249
+ # Waiver Support (Phase 4)
250
+ #===============================================================================
251
+
252
+ # Load waivers from .loki/checklist/waivers.json
253
+ # Returns waived item IDs (one per line) to stdout
254
+ checklist_waiver_load() {
255
+ local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
256
+ if [ ! -f "$waivers_file" ]; then
257
+ return 0
258
+ fi
259
+ _WAIVERS_FILE="$waivers_file" python3 -c "
260
+ import json, sys, os
261
+ try:
262
+ waivers_file = os.environ['_WAIVERS_FILE']
263
+ with open(waivers_file) as f:
264
+ waivers = json.load(f)
265
+ for w in waivers.get('waivers', []):
266
+ if w.get('active', True):
267
+ print(w['item_id'])
268
+ except Exception:
269
+ pass
270
+ " 2>/dev/null || true
271
+ }
272
+
273
+ # Add a waiver for a checklist item
274
+ # Usage: checklist_waiver_add <item_id> <reason> [waived_by]
275
+ checklist_waiver_add() {
276
+ local item_id="${1:?item_id required}"
277
+ local reason="${2:?reason required}"
278
+ local waived_by="${3:-manual}"
279
+ local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
280
+ local timestamp
281
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
282
+
283
+ _WAIVERS_FILE="$waivers_file" python3 -c "
284
+ import json, os, sys
285
+
286
+ waivers_file = os.environ['_WAIVERS_FILE']
287
+ item_id = sys.argv[1]
288
+ reason = sys.argv[2]
289
+ waived_by = sys.argv[3]
290
+ timestamp = sys.argv[4]
291
+
292
+ # Load existing or create new
293
+ waivers = {'waivers': []}
294
+ if os.path.exists(waivers_file):
295
+ try:
296
+ with open(waivers_file) as f:
297
+ waivers = json.load(f)
298
+ except (json.JSONDecodeError, IOError):
299
+ pass
300
+
301
+ # Check for duplicate
302
+ for w in waivers.get('waivers', []):
303
+ if w.get('item_id') == item_id and w.get('active', True):
304
+ print(f'Waiver already exists for {item_id}')
305
+ sys.exit(0)
306
+
307
+ # Add new waiver
308
+ waivers.setdefault('waivers', []).append({
309
+ 'item_id': item_id,
310
+ 'reason': reason,
311
+ 'waived_by': waived_by,
312
+ 'waived_at': timestamp,
313
+ 'active': True
314
+ })
315
+
316
+ # Atomic write
317
+ tmp = waivers_file + '.tmp'
318
+ with open(tmp, 'w') as f:
319
+ json.dump(waivers, f, indent=2)
320
+ os.replace(tmp, waivers_file)
321
+ print(f'Waiver added for {item_id}')
322
+ " "$item_id" "$reason" "$waived_by" "$timestamp" 2>/dev/null
323
+ }
324
+
325
+ # Remove (deactivate) a waiver for a checklist item
326
+ # Usage: checklist_waiver_remove <item_id>
327
+ checklist_waiver_remove() {
328
+ local item_id="${1:?item_id required}"
329
+ local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
330
+
331
+ if [ ! -f "$waivers_file" ]; then
332
+ echo "No waivers file found"
333
+ return 1
334
+ fi
335
+
336
+ _WAIVERS_FILE="$waivers_file" python3 -c "
337
+ import json, os, sys
338
+
339
+ waivers_file = os.environ['_WAIVERS_FILE']
340
+ item_id = sys.argv[1]
341
+
342
+ with open(waivers_file) as f:
343
+ waivers = json.load(f)
344
+
345
+ found = False
346
+ for w in waivers.get('waivers', []):
347
+ if w.get('item_id') == item_id and w.get('active', True):
348
+ w['active'] = False
349
+ found = True
350
+
351
+ if not found:
352
+ print(f'No active waiver found for {item_id}')
353
+ sys.exit(1)
354
+
355
+ tmp = waivers_file + '.tmp'
356
+ with open(tmp, 'w') as f:
357
+ json.dump(waivers, f, indent=2)
358
+ os.replace(tmp, waivers_file)
359
+ print(f'Waiver removed for {item_id}')
360
+ " "$item_id" 2>/dev/null
361
+ }
362
+
363
+ # List all active waivers
364
+ checklist_waiver_list() {
365
+ local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
366
+
367
+ if [ ! -f "$waivers_file" ]; then
368
+ echo "No waivers configured"
369
+ return 0
370
+ fi
371
+
372
+ _WAIVERS_FILE="$waivers_file" python3 -c "
373
+ import json, os
374
+ waivers_file = os.environ['_WAIVERS_FILE']
375
+ with open(waivers_file) as f:
376
+ waivers = json.load(f)
377
+ active = [w for w in waivers.get('waivers', []) if w.get('active', True)]
378
+ if not active:
379
+ print('No active waivers')
380
+ else:
381
+ for w in active:
382
+ print(f\" {w['item_id']}: {w.get('reason', 'no reason')} (by {w.get('waived_by', 'unknown')} at {w.get('waived_at', '?')})\")
383
+ " 2>/dev/null
384
+ }
package/autonomy/run.sh CHANGED
@@ -6843,7 +6843,9 @@ check_human_intervention() {
6843
6843
  if [ -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED" ]; then
6844
6844
  log_info "Council force-review requested from dashboard"
6845
6845
  rm -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED"
6846
- if type council_vote &>/dev/null && council_vote; then
6846
+ if type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
6847
+ log_info "Council force-review: blocked by checklist hard gate"
6848
+ elif type council_vote &>/dev/null && council_vote; then
6847
6849
  log_header "COMPLETION COUNCIL: FORCE REVIEW - PROJECT COMPLETE"
6848
6850
  # BUG #17 fix: Write COMPLETED marker, generate council report, and
6849
6851
  # run memory consolidation (matching the normal council approval path
@@ -7452,6 +7454,9 @@ main() {
7452
7454
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
7453
7455
 
7454
7456
  # Cleanup
7457
+ if type app_runner_cleanup &>/dev/null; then
7458
+ app_runner_cleanup
7459
+ fi
7455
7460
  stop_dashboard
7456
7461
  stop_status_monitor
7457
7462
  rm -f .loki/loki.pid 2>/dev/null
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.46.0"
10
+ __version__ = "5.47.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -26,7 +26,7 @@ from fastapi import (
26
26
  WebSocketDisconnect,
27
27
  )
28
28
  from fastapi.middleware.cors import CORSMiddleware
29
- from fastapi.responses import PlainTextResponse
29
+ from fastapi.responses import JSONResponse, PlainTextResponse
30
30
  from pydantic import BaseModel, Field
31
31
  from sqlalchemy import select, update, delete
32
32
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -3402,6 +3402,132 @@ async def get_prd_observations():
3402
3402
  return PlainTextResponse("Error reading PRD observations.", status_code=500)
3403
3403
 
3404
3404
 
3405
+ # =============================================================================
3406
+ # Checklist Waiver Management Endpoints (Phase 4)
3407
+ # =============================================================================
3408
+
3409
+ @app.get("/api/checklist/waivers")
3410
+ async def get_checklist_waivers():
3411
+ """Get all checklist waivers."""
3412
+ waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
3413
+ if not waivers_file.exists():
3414
+ return {"waivers": []}
3415
+ try:
3416
+ return json.loads(waivers_file.read_text())
3417
+ except (json.JSONDecodeError, IOError):
3418
+ return {"waivers": [], "error": "Failed to read waivers file"}
3419
+
3420
+
3421
+ @app.post("/api/checklist/waivers", dependencies=[Depends(auth.require_scope("control"))])
3422
+ async def add_checklist_waiver(request: Request):
3423
+ """Add a waiver for a checklist item."""
3424
+ if not _control_limiter.check("control"):
3425
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
3426
+ try:
3427
+ body = await request.json()
3428
+ except Exception:
3429
+ return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
3430
+
3431
+ item_id = body.get("item_id")
3432
+ reason = body.get("reason")
3433
+ if not item_id or not reason:
3434
+ return JSONResponse(status_code=400, content={"error": "item_id and reason required"})
3435
+
3436
+ if not isinstance(reason, str) or len(reason) > 1024:
3437
+ return JSONResponse(status_code=400, content={"error": "reason must be a string (max 1024 chars)"})
3438
+
3439
+ # Sanitize item_id: non-empty, max 256 chars, no path traversal
3440
+ if not isinstance(item_id, str) or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
3441
+ return JSONResponse(status_code=400, content={"error": "Invalid item_id: must be 1-256 chars, no path traversal characters"})
3442
+
3443
+ waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
3444
+
3445
+ # Load existing
3446
+ waivers = {"waivers": []}
3447
+ if waivers_file.exists():
3448
+ try:
3449
+ waivers = json.loads(waivers_file.read_text())
3450
+ except (json.JSONDecodeError, IOError):
3451
+ pass
3452
+
3453
+ # Check duplicate
3454
+ for w in waivers.get("waivers", []):
3455
+ if w.get("item_id") == item_id and w.get("active", True):
3456
+ return JSONResponse(status_code=409, content={"status": "already_exists", "item_id": item_id})
3457
+
3458
+ # Add waiver
3459
+ waiver = {
3460
+ "item_id": item_id,
3461
+ "reason": reason,
3462
+ "waived_by": body.get("waived_by", "dashboard"),
3463
+ "waived_at": datetime.now(timezone.utc).isoformat(),
3464
+ "active": True
3465
+ }
3466
+ waivers.setdefault("waivers", []).append(waiver)
3467
+
3468
+ # Ensure directory exists
3469
+ waivers_file.parent.mkdir(parents=True, exist_ok=True)
3470
+
3471
+ # Atomic write
3472
+ tmp_file = waivers_file.with_suffix(".tmp")
3473
+ tmp_file.write_text(json.dumps(waivers, indent=2))
3474
+ tmp_file.replace(waivers_file)
3475
+
3476
+ return {"status": "added", "waiver": waiver}
3477
+
3478
+
3479
+ @app.delete("/api/checklist/waivers/{item_id}", dependencies=[Depends(auth.require_scope("control"))])
3480
+ async def remove_checklist_waiver(item_id: str):
3481
+ """Deactivate a waiver for a checklist item."""
3482
+ if not _control_limiter.check("control"):
3483
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
3484
+
3485
+ # Sanitize item_id: non-empty, max 256 chars, no path traversal
3486
+ if not item_id or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
3487
+ raise HTTPException(status_code=400, detail="Invalid item_id: must be 1-256 chars, no path traversal characters")
3488
+
3489
+ waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
3490
+ if not waivers_file.exists():
3491
+ return JSONResponse(status_code=404, content={"error": "No waivers file"})
3492
+
3493
+ try:
3494
+ waivers = json.loads(waivers_file.read_text())
3495
+ except (json.JSONDecodeError, IOError):
3496
+ return JSONResponse(status_code=500, content={"error": "Failed to read waivers"})
3497
+
3498
+ found = False
3499
+ for w in waivers.get("waivers", []):
3500
+ if w.get("item_id") == item_id and w.get("active", True):
3501
+ w["active"] = False
3502
+ found = True
3503
+
3504
+ if not found:
3505
+ return JSONResponse(status_code=404, content={"error": f"No active waiver for {item_id}"})
3506
+
3507
+ # Atomic write
3508
+ tmp_file = waivers_file.with_suffix(".tmp")
3509
+ tmp_file.write_text(json.dumps(waivers, indent=2))
3510
+ tmp_file.replace(waivers_file)
3511
+
3512
+ return {"status": "removed", "item_id": item_id}
3513
+
3514
+
3515
+ # =============================================================================
3516
+ # Council Hard Gate Endpoint (Phase 4)
3517
+ # =============================================================================
3518
+
3519
+ @app.get("/api/council/gate")
3520
+ async def get_council_gate():
3521
+ """Get council hard gate status."""
3522
+ gate_file = _get_loki_dir() / "council" / "gate-block.json"
3523
+ if not gate_file.exists():
3524
+ return {"blocked": False}
3525
+ try:
3526
+ return json.loads(gate_file.read_text())
3527
+ except (json.JSONDecodeError, IOError):
3528
+ return {"blocked": False, "error": "Failed to read gate file"}
3529
+
3530
+
3405
3531
  # =============================================================================
3406
3532
  # App Runner Endpoints (v5.45.0)
3407
3533
  # =============================================================================
@@ -3436,7 +3562,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
3436
3562
  @app.post("/api/control/app-restart", dependencies=[Depends(auth.require_scope("control"))])
3437
3563
  async def control_app_restart(request: Request):
3438
3564
  """Signal app runner to restart the application."""
3439
- if not _control_limiter.check(str(request.client.host)):
3565
+ if not _control_limiter.check(request.client.host if request.client else "unknown"):
3440
3566
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
3441
3567
  loki_dir = _get_loki_dir()
3442
3568
  signal_dir = loki_dir / "app-runner"
@@ -3449,7 +3575,7 @@ async def control_app_restart(request: Request):
3449
3575
  @app.post("/api/control/app-stop", dependencies=[Depends(auth.require_scope("control"))])
3450
3576
  async def control_app_stop(request: Request):
3451
3577
  """Signal app runner to stop the application."""
3452
- if not _control_limiter.check(str(request.client.host)):
3578
+ if not _control_limiter.check(request.client.host if request.client else "unknown"):
3453
3579
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
3454
3580
  loki_dir = _get_loki_dir()
3455
3581
  signal_dir = loki_dir / "app-runner"
@@ -3487,7 +3613,7 @@ async def get_playwright_screenshot():
3487
3613
  screenshots = sorted(screenshots_dir.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
3488
3614
  if not screenshots:
3489
3615
  return {"screenshot": None}
3490
- return {"screenshot": str(screenshots[0])}
3616
+ return FileResponse(str(screenshots[0]), media_type="image/png")
3491
3617
 
3492
3618
 
3493
3619
  # =============================================================================