loki-mode 7.78.0 → 7.80.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/autonomy/run.sh CHANGED
@@ -40,12 +40,18 @@
40
40
  # LOKI_AUDIT_DISABLED - Disable audit logging (default: false)
41
41
  # LOKI_MAX_PARALLEL_AGENTS - Limit concurrent agent spawning (default: 10)
42
42
  # LOKI_SANDBOX_MODE - Run in sandboxed container (default: false, requires Docker)
43
- # LOKI_ALLOWED_PATHS - Reserved: comma-separated path allowlist. NOT YET
44
- # ENFORCED by the runtime (the value is read from
45
- # config but no path-restriction check consumes it).
46
- # Do not rely on it as a security control. Agent file
47
- # writes are constrained by the OS user + container
48
- # sandbox (LOKI_SANDBOX_MODE), not by this var.
43
+ # LOKI_ALLOWED_PATHS - Comma-separated path allowlist. PARTIAL enforcement
44
+ # (honest scope): when set, run.sh rejects a sandbox
45
+ # custom --mount whose host path is outside the
46
+ # allowlist (a write surface run.sh itself controls).
47
+ # It does NOT and CANNOT restrict provider-driven
48
+ # agent file writes: claude/codex/cline/aider write
49
+ # files directly, not through run.sh, so run.sh never
50
+ # sees those writes. The containment for agent writes
51
+ # is the OS user + the LOKI_SANDBOX_MODE container
52
+ # (cap-drop, seccomp, read-only mounts), not this var.
53
+ # Treat ALLOWED_PATHS as defense-in-depth on the
54
+ # mount surface, not a complete write sandbox.
49
55
  # LOKI_BLOCKED_COMMANDS - Comma-separated blocked shell commands (default: rm -rf /)
50
56
  #
51
57
  # OIDC / SSO Authentication (optional, works alongside token auth):
@@ -230,6 +236,17 @@ fi
230
236
  # Configuration File Support (v4.1.0)
231
237
  # Loads settings from config file, environment variables take precedence
232
238
  #===============================================================================
239
+ # #691: source the canonical config mapping + shared per-key export helper. This
240
+ # is the SAME lib the loki CLI pre-pass uses, so the YAML/config-file mapping
241
+ # cannot drift between the CLI and the runner. Side-effect-free on source. The
242
+ # parsers below delegate to loki_config_export_key with override=0, preserving
243
+ # the shipped env-wins auto-discovery contract byte-for-byte.
244
+ _LOKI_CONFIG_MAP_LIB="$SCRIPT_DIR/lib/config-map.sh"
245
+ if [ -f "$_LOKI_CONFIG_MAP_LIB" ]; then
246
+ # shellcheck source=lib/config-map.sh
247
+ source "$_LOKI_CONFIG_MAP_LIB"
248
+ fi
249
+
233
250
  load_config_file() {
234
251
  local config_file=""
235
252
 
@@ -263,95 +280,22 @@ load_config_file() {
263
280
  parse_yaml_with_yq "$config_file"
264
281
  }
265
282
 
266
- # Fallback YAML parser for simple key: value format
283
+ # Fallback YAML parser for simple key: value format.
284
+ # #691: iterates the canonical LOKI_CONFIG_MAP (single source of truth) instead
285
+ # of 64 hand-maintained set_from_yaml calls. override=0 -> ambient env still
286
+ # wins (auto-discovery contract unchanged). Falls back to nothing if the lib is
287
+ # absent (defensive; the lib ships alongside run.sh).
267
288
  parse_simple_yaml() {
268
289
  local file="$1"
269
-
270
- # Parse core settings
271
- set_from_yaml "$file" "core.max_retries" "LOKI_MAX_RETRIES"
272
- set_from_yaml "$file" "core.base_wait" "LOKI_BASE_WAIT"
273
- set_from_yaml "$file" "core.max_wait" "LOKI_MAX_WAIT"
274
- set_from_yaml "$file" "core.skip_prereqs" "LOKI_SKIP_PREREQS"
275
-
276
- # Dashboard
277
- set_from_yaml "$file" "dashboard.enabled" "LOKI_DASHBOARD"
278
- set_from_yaml "$file" "dashboard.port" "LOKI_DASHBOARD_PORT"
279
-
280
- # Resources
281
- set_from_yaml "$file" "resources.check_interval" "LOKI_RESOURCE_CHECK_INTERVAL"
282
- set_from_yaml "$file" "resources.cpu_threshold" "LOKI_RESOURCE_CPU_THRESHOLD"
283
- set_from_yaml "$file" "resources.mem_threshold" "LOKI_RESOURCE_MEM_THRESHOLD"
284
-
285
- # Security
286
- set_from_yaml "$file" "security.staged_autonomy" "LOKI_STAGED_AUTONOMY"
287
- set_from_yaml "$file" "security.audit_log" "LOKI_AUDIT_LOG"
288
- set_from_yaml "$file" "security.max_parallel_agents" "LOKI_MAX_PARALLEL_AGENTS"
289
- set_from_yaml "$file" "security.sandbox_mode" "LOKI_SANDBOX_MODE"
290
- set_from_yaml "$file" "security.allowed_paths" "LOKI_ALLOWED_PATHS"
291
- set_from_yaml "$file" "security.blocked_commands" "LOKI_BLOCKED_COMMANDS"
292
-
293
- # Phases
294
- set_from_yaml "$file" "phases.unit_tests" "LOKI_PHASE_UNIT_TESTS"
295
- set_from_yaml "$file" "phases.api_tests" "LOKI_PHASE_API_TESTS"
296
- set_from_yaml "$file" "phases.e2e_tests" "LOKI_PHASE_E2E_TESTS"
297
- set_from_yaml "$file" "phases.security" "LOKI_PHASE_SECURITY"
298
- set_from_yaml "$file" "phases.integration" "LOKI_PHASE_INTEGRATION"
299
- set_from_yaml "$file" "phases.code_review" "LOKI_PHASE_CODE_REVIEW"
300
- set_from_yaml "$file" "phases.web_research" "LOKI_PHASE_WEB_RESEARCH"
301
- set_from_yaml "$file" "phases.performance" "LOKI_PHASE_PERFORMANCE"
302
- set_from_yaml "$file" "phases.accessibility" "LOKI_PHASE_ACCESSIBILITY"
303
- set_from_yaml "$file" "phases.regression" "LOKI_PHASE_REGRESSION"
304
- set_from_yaml "$file" "phases.uat" "LOKI_PHASE_UAT"
305
-
306
- # Completion
307
- set_from_yaml "$file" "completion.promise" "LOKI_COMPLETION_PROMISE"
308
- set_from_yaml "$file" "completion.max_iterations" "LOKI_MAX_ITERATIONS"
309
- set_from_yaml "$file" "completion.perpetual_mode" "LOKI_PERPETUAL_MODE"
310
- set_from_yaml "$file" "completion.council.enabled" "LOKI_COUNCIL_ENABLED"
311
- set_from_yaml "$file" "completion.council.size" "LOKI_COUNCIL_SIZE"
312
- set_from_yaml "$file" "completion.council.threshold" "LOKI_COUNCIL_THRESHOLD"
313
- set_from_yaml "$file" "completion.council.check_interval" "LOKI_COUNCIL_CHECK_INTERVAL"
314
- set_from_yaml "$file" "completion.council.min_iterations" "LOKI_COUNCIL_MIN_ITERATIONS"
315
- set_from_yaml "$file" "completion.council.stagnation_limit" "LOKI_COUNCIL_STAGNATION_LIMIT"
316
- set_from_yaml "$file" "completion.uncertainty.escalation" "LOKI_UNCERTAINTY_ESCALATION"
317
- set_from_yaml "$file" "completion.uncertainty.rounds" "LOKI_UNCERTAINTY_ROUNDS"
318
- set_from_yaml "$file" "completion.uncertainty.nochange_min" "LOKI_UNCERTAINTY_NOCHANGE_MIN"
319
- set_from_yaml "$file" "completion.uncertainty.split_rounds" "LOKI_UNCERTAINTY_SPLIT_ROUNDS"
320
-
321
- # Model
322
- set_from_yaml "$file" "model.prompt_repetition" "LOKI_PROMPT_REPETITION"
323
- set_from_yaml "$file" "model.confidence_routing" "LOKI_CONFIDENCE_ROUTING"
324
- set_from_yaml "$file" "model.autonomy_mode" "LOKI_AUTONOMY_MODE"
325
- set_from_yaml "$file" "model.planning" "LOKI_MODEL_PLANNING"
326
- set_from_yaml "$file" "model.development" "LOKI_MODEL_DEVELOPMENT"
327
- set_from_yaml "$file" "model.fast" "LOKI_MODEL_FAST"
328
-
329
- # Parallel
330
- set_from_yaml "$file" "parallel.enabled" "LOKI_PARALLEL_MODE"
331
- set_from_yaml "$file" "parallel.max_worktrees" "LOKI_MAX_WORKTREES"
332
- set_from_yaml "$file" "parallel.max_sessions" "LOKI_MAX_PARALLEL_SESSIONS"
333
- set_from_yaml "$file" "parallel.testing" "LOKI_PARALLEL_TESTING"
334
- set_from_yaml "$file" "parallel.docs" "LOKI_PARALLEL_DOCS"
335
- set_from_yaml "$file" "parallel.blog" "LOKI_PARALLEL_BLOG"
336
- set_from_yaml "$file" "parallel.auto_merge" "LOKI_AUTO_MERGE"
337
-
338
- # Complexity
339
- set_from_yaml "$file" "complexity.tier" "LOKI_COMPLEXITY"
340
-
341
- # GitHub
342
- set_from_yaml "$file" "github.import" "LOKI_GITHUB_IMPORT"
343
- set_from_yaml "$file" "github.pr" "LOKI_GITHUB_PR"
344
- set_from_yaml "$file" "github.sync" "LOKI_GITHUB_SYNC"
345
- set_from_yaml "$file" "github.repo" "LOKI_GITHUB_REPO"
346
- set_from_yaml "$file" "github.labels" "LOKI_GITHUB_LABELS"
347
- set_from_yaml "$file" "github.milestone" "LOKI_GITHUB_MILESTONE"
348
- set_from_yaml "$file" "github.assignee" "LOKI_GITHUB_ASSIGNEE"
349
- set_from_yaml "$file" "github.limit" "LOKI_GITHUB_LIMIT"
350
- set_from_yaml "$file" "github.pr_label" "LOKI_GITHUB_PR_LABEL"
351
-
352
- # Notifications
353
- set_from_yaml "$file" "notifications.enabled" "LOKI_NOTIFICATIONS"
354
- set_from_yaml "$file" "notifications.sound" "LOKI_NOTIFICATION_SOUND"
290
+ if ! declare -p LOKI_CONFIG_MAP >/dev/null 2>&1; then
291
+ return 0
292
+ fi
293
+ local mapping yaml_path env_var
294
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
295
+ yaml_path="${mapping%%:*}"
296
+ env_var="${mapping##*:}"
297
+ set_from_yaml "$file" "$yaml_path" "$env_var"
298
+ done
355
299
  }
356
300
 
357
301
  # Validate YAML value to prevent injection attacks
@@ -364,11 +308,19 @@ validate_yaml_value() {
364
308
  return 1
365
309
  fi
366
310
 
367
- # Reject values with dangerous shell metacharacters
368
- # Allow alphanumeric, spaces, dots, dashes, underscores, slashes, colons, commas, @
369
- if [[ "$value" =~ [\$\`\|\;\&\>\<\(\)\{\}\[\]\\] ]]; then
370
- return 1
371
- fi
311
+ # Reject values with dangerous shell metacharacters.
312
+ # SECURITY FIX (#691 wave): the previous [[ "$value" =~ [\$\`...] ]]
313
+ # bracket-regex matched NOTHING -- backslash escapes inside a bash regex
314
+ # bracket class are taken literally, so the guard ACCEPTED $(...), backticks,
315
+ # pipes, etc. (verified behaviorally: it returned 0 for "$(touch /tmp/x)").
316
+ # A `case` glob class has no such ambiguity and actually rejects the metachars.
317
+ # Allow alphanumeric, spaces, dots, dashes, underscores, slashes, colons,
318
+ # commas, @.
319
+ case "$value" in
320
+ *'$'* | *'`'* | *'|'* | *';'* | *'&'* | *'>'* | *'<'* | *'('* | *')'* \
321
+ | *'{'* | *'}'* | *'['* | *']'* | *'\'* )
322
+ return 1 ;;
323
+ esac
372
324
 
373
325
  # Reject values that are too long (DoS protection)
374
326
  if [ "${#value}" -gt "$max_length" ]; then
@@ -396,7 +348,8 @@ set_from_yaml() {
396
348
  local yaml_path="$2"
397
349
  local env_var="$3"
398
350
 
399
- # Skip if env var is already set
351
+ # Skip if env var is already set (env-wins guard; the shared helper also
352
+ # enforces this with override=0, but checking here avoids the extraction).
400
353
  if [ -n "${!env_var:-}" ]; then
401
354
  return 0
402
355
  fi
@@ -414,95 +367,40 @@ set_from_yaml() {
414
367
  # Use read to avoid xargs command execution risks
415
368
  value=$(grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 | sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["\x27]//;s/["\x27]$//' | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
416
369
 
417
- # Validate value before export (security check)
418
- if [ -n "$value" ] && [ "$value" != "null" ] && validate_yaml_value "$value"; then
370
+ # #691: delegate the env-wins guard + ${VAR}-expand + validate + export to the
371
+ # single shared helper (override=0 -> ambient env wins). Falls back to the
372
+ # original inline export if the lib is unavailable, so behavior is identical
373
+ # either way.
374
+ if declare -f loki_config_export_key >/dev/null 2>&1; then
375
+ loki_config_export_key "$env_var" "$value" 0 || true
376
+ elif [ -n "$value" ] && [ "$value" != "null" ] && validate_yaml_value "$value"; then
419
377
  export "$env_var=$value"
420
378
  fi
421
379
  }
422
380
 
423
- # Parse YAML using yq (proper parser)
381
+ # Parse YAML using yq (proper parser).
382
+ # #691: iterates the canonical LOKI_CONFIG_MAP and delegates the env-wins guard +
383
+ # expand + validate + export to the single shared helper (override=0 -> ambient
384
+ # env wins; auto-discovery contract unchanged). The inline 61-entry table that
385
+ # used to drift from parse_simple_yaml's 64 is gone.
424
386
  parse_yaml_with_yq() {
425
387
  local file="$1"
426
- local mappings=(
427
- "core.max_retries:LOKI_MAX_RETRIES"
428
- "core.base_wait:LOKI_BASE_WAIT"
429
- "core.max_wait:LOKI_MAX_WAIT"
430
- "core.skip_prereqs:LOKI_SKIP_PREREQS"
431
- "dashboard.enabled:LOKI_DASHBOARD"
432
- "dashboard.port:LOKI_DASHBOARD_PORT"
433
- "resources.check_interval:LOKI_RESOURCE_CHECK_INTERVAL"
434
- "resources.cpu_threshold:LOKI_RESOURCE_CPU_THRESHOLD"
435
- "resources.mem_threshold:LOKI_RESOURCE_MEM_THRESHOLD"
436
- "security.staged_autonomy:LOKI_STAGED_AUTONOMY"
437
- "security.audit_log:LOKI_AUDIT_LOG"
438
- "security.max_parallel_agents:LOKI_MAX_PARALLEL_AGENTS"
439
- "security.sandbox_mode:LOKI_SANDBOX_MODE"
440
- "security.allowed_paths:LOKI_ALLOWED_PATHS"
441
- "security.blocked_commands:LOKI_BLOCKED_COMMANDS"
442
- "phases.unit_tests:LOKI_PHASE_UNIT_TESTS"
443
- "phases.api_tests:LOKI_PHASE_API_TESTS"
444
- "phases.e2e_tests:LOKI_PHASE_E2E_TESTS"
445
- "phases.security:LOKI_PHASE_SECURITY"
446
- "phases.integration:LOKI_PHASE_INTEGRATION"
447
- "phases.code_review:LOKI_PHASE_CODE_REVIEW"
448
- "phases.web_research:LOKI_PHASE_WEB_RESEARCH"
449
- "phases.performance:LOKI_PHASE_PERFORMANCE"
450
- "phases.accessibility:LOKI_PHASE_ACCESSIBILITY"
451
- "phases.regression:LOKI_PHASE_REGRESSION"
452
- "phases.uat:LOKI_PHASE_UAT"
453
- "completion.promise:LOKI_COMPLETION_PROMISE"
454
- "completion.max_iterations:LOKI_MAX_ITERATIONS"
455
- "completion.perpetual_mode:LOKI_PERPETUAL_MODE"
456
- "completion.council.enabled:LOKI_COUNCIL_ENABLED"
457
- "completion.council.size:LOKI_COUNCIL_SIZE"
458
- "completion.council.threshold:LOKI_COUNCIL_THRESHOLD"
459
- "completion.council.check_interval:LOKI_COUNCIL_CHECK_INTERVAL"
460
- "completion.council.min_iterations:LOKI_COUNCIL_MIN_ITERATIONS"
461
- "completion.council.stagnation_limit:LOKI_COUNCIL_STAGNATION_LIMIT"
462
- "completion.uncertainty.escalation:LOKI_UNCERTAINTY_ESCALATION"
463
- "completion.uncertainty.rounds:LOKI_UNCERTAINTY_ROUNDS"
464
- "completion.uncertainty.nochange_min:LOKI_UNCERTAINTY_NOCHANGE_MIN"
465
- "completion.uncertainty.split_rounds:LOKI_UNCERTAINTY_SPLIT_ROUNDS"
466
- "model.prompt_repetition:LOKI_PROMPT_REPETITION"
467
- "model.confidence_routing:LOKI_CONFIDENCE_ROUTING"
468
- "model.autonomy_mode:LOKI_AUTONOMY_MODE"
469
- "parallel.enabled:LOKI_PARALLEL_MODE"
470
- "parallel.max_worktrees:LOKI_MAX_WORKTREES"
471
- "parallel.max_sessions:LOKI_MAX_PARALLEL_SESSIONS"
472
- "parallel.testing:LOKI_PARALLEL_TESTING"
473
- "parallel.docs:LOKI_PARALLEL_DOCS"
474
- "parallel.blog:LOKI_PARALLEL_BLOG"
475
- "parallel.auto_merge:LOKI_AUTO_MERGE"
476
- "complexity.tier:LOKI_COMPLEXITY"
477
- "github.import:LOKI_GITHUB_IMPORT"
478
- "github.pr:LOKI_GITHUB_PR"
479
- "github.sync:LOKI_GITHUB_SYNC"
480
- "github.repo:LOKI_GITHUB_REPO"
481
- "github.labels:LOKI_GITHUB_LABELS"
482
- "github.milestone:LOKI_GITHUB_MILESTONE"
483
- "github.assignee:LOKI_GITHUB_ASSIGNEE"
484
- "github.limit:LOKI_GITHUB_LIMIT"
485
- "github.pr_label:LOKI_GITHUB_PR_LABEL"
486
- "notifications.enabled:LOKI_NOTIFICATIONS"
487
- "notifications.sound:LOKI_NOTIFICATION_SOUND"
488
- )
388
+ if ! declare -p LOKI_CONFIG_MAP >/dev/null 2>&1; then
389
+ return 0
390
+ fi
489
391
 
490
- for mapping in "${mappings[@]}"; do
491
- local yaml_path="${mapping%%:*}"
492
- local env_var="${mapping##*:}"
392
+ local mapping yaml_path env_var value
393
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
394
+ yaml_path="${mapping%%:*}"
395
+ env_var="${mapping##*:}"
493
396
 
494
- # Skip if env var is already set
495
- if [ -n "${!env_var:-}" ]; then
496
- continue
497
- fi
498
-
499
- # Extract value using yq
500
- local value
397
+ # Extract value using yq (env-wins guard is enforced by the shared helper).
501
398
  value=$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null)
502
399
 
503
- # Set env var if value found and not empty/null
504
- # Also validate for security (prevent injection)
505
- if [ -n "$value" ] && [ "$value" != "null" ] && [ "$value" != "" ] && validate_yaml_value "$value"; then
400
+ if declare -f loki_config_export_key >/dev/null 2>&1; then
401
+ loki_config_export_key "$env_var" "$value" 0 || true
402
+ elif [ -n "$value" ] && [ "$value" != "null" ] && [ "$value" != "" ] \
403
+ && [ -z "${!env_var:-}" ] && validate_yaml_value "$value"; then
506
404
  export "$env_var=$value"
507
405
  fi
508
406
  done
@@ -593,9 +491,29 @@ STAGED_AUTONOMY=${LOKI_STAGED_AUTONOMY:-false} # Require plan approval
593
491
  AUDIT_LOG_ENABLED=${LOKI_AUDIT_LOG:-true} # Enable audit logging (on by default)
594
492
  MAX_PARALLEL_AGENTS=${LOKI_MAX_PARALLEL_AGENTS:-10} # Limit concurrent agents
595
493
  SANDBOX_MODE=${LOKI_SANDBOX_MODE:-false} # Docker sandbox mode (informational; the real dispatch reads LOKI_SANDBOX_MODE at autonomy/loki:1965 and execs sandbox.sh -- this var is not consumed in run.sh)
596
- ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""} # Reserved, NOT enforced (see header note). No runtime path check reads this; kept only so config plumbing + a future enforcement wiring have a single var to bind. Not a security control today.
494
+ ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""} # PARTIAL enforcement. When set, a sandbox custom --mount host path outside the allowlist is rejected in sandbox.sh (_sandbox_path_within_allowed), NOT in run.sh. Does NOT restrict provider-driven agent writes (run.sh never sees them); the sandbox container is the containment for those. Default empty = no restriction.
597
495
  BLOCKED_COMMANDS=${LOKI_BLOCKED_COMMANDS:-"rm -rf /,dd if=,mkfs,:(){ :|:& };:"}
598
496
 
497
+ # LOKI_SESSION_ID is woven into many filesystem paths (.loki/sessions/<id>/...,
498
+ # session locks, PID files, per-session state). It is operator/issue-derived
499
+ # (trusted-ish), but a value like "../../tmp/x" would escape the .loki/ tree.
500
+ # Sanitize ONCE here at intake: any char outside [A-Za-z0-9._-] is replaced with
501
+ # "_" and a leading run of dots/dashes is neutralized, so every downstream
502
+ # session path is contained. Unset stays unset (default behavior unchanged).
503
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
504
+ _loki_sid_raw="$LOKI_SESSION_ID"
505
+ _loki_sid_safe="${_loki_sid_raw//[^A-Za-z0-9._-]/_}"
506
+ # Strip leading dots/dashes so "../x" -> "x" cannot reference a parent dir
507
+ # or look like an option; collapse a now-empty result to a safe literal.
508
+ _loki_sid_safe="${_loki_sid_safe#"${_loki_sid_safe%%[!.-]*}"}"
509
+ [ -n "$_loki_sid_safe" ] || _loki_sid_safe="session"
510
+ if [ "$_loki_sid_safe" != "$_loki_sid_raw" ]; then
511
+ printf 'loki: WARNING sanitized unsafe LOKI_SESSION_ID %s -> %s\n' "$_loki_sid_raw" "$_loki_sid_safe" >&2
512
+ fi
513
+ export LOKI_SESSION_ID="$_loki_sid_safe"
514
+ unset _loki_sid_raw _loki_sid_safe
515
+ fi
516
+
599
517
  # Process Supervision (opt-in)
600
518
  WATCHDOG_ENABLED=${LOKI_WATCHDOG:-"false"} # Enable process health monitoring
601
519
  WATCHDOG_INTERVAL=${LOKI_WATCHDOG_INTERVAL:-30} # Check interval in seconds
@@ -5335,6 +5253,9 @@ print_ttfv_next_steps() {
5335
5253
  echo " loki start ./prd.md # build from a full PRD"
5336
5254
  echo " loki start \"<one line>\" # fast first pass from a brief"
5337
5255
  fi
5256
+ # Frictionless discovery: surface 'loki why' so users learn the
5257
+ # what-happened/what-to-do diagnosis without having to know it exists.
5258
+ echo " loki why # explain this outcome + what to do next"
5338
5259
  echo ""
5339
5260
  return 0
5340
5261
  }
@@ -6275,6 +6196,28 @@ check_command_allowed() {
6275
6196
  return 0
6276
6197
  }
6277
6198
 
6199
+ # A5 (ALLOWED_PATHS enforcement) -- HONEST THREAT MODEL
6200
+ # --------------------------------------------------------------------------
6201
+ # The DOMINANT file-write path in an autonomous build is the PROVIDER CLI
6202
+ # (claude / codex / cline / aider) writing files directly. run.sh hands the
6203
+ # work to that CLI and never sees those writes as shell redirections, so run.sh
6204
+ # CANNOT intercept provider-driven writes. ALLOWED_PATHS therefore cannot be a
6205
+ # complete sandbox; the real containment for provider writes is the OS user +
6206
+ # the LOKI_SANDBOX_MODE container (sandbox.sh, cap-drop/seccomp/read-only
6207
+ # mounts), not this shell.
6208
+ #
6209
+ # Where ALLOWED_PATHS is actually enforced: the sandbox custom --mount surface.
6210
+ # That enforcement lives in sandbox.sh, NOT here. When ALLOWED_PATHS is set, a
6211
+ # host path that loki is about to bind-mount into the container is checked by
6212
+ # _sandbox_path_within_allowed (autonomy/sandbox.sh), and an operator-supplied
6213
+ # `loki sandbox run` argv is checked by _sandbox_command_allowed there too.
6214
+ # run.sh itself does not consume ALLOWED_PATHS for any write decision -- it has
6215
+ # no run.sh-controlled host-write surface to gate -- so there is no enforcement
6216
+ # helper here. (A run.sh-local helper existed previously but had zero call
6217
+ # sites; it was removed so the only enforcement path is the one that genuinely
6218
+ # runs. See tests/test-allowed-paths-a5.sh, which exercises the sandbox.sh
6219
+ # functions.)
6220
+
6278
6221
  #===============================================================================
6279
6222
  # Cross-Project Learnings Database
6280
6223
  #===============================================================================
@@ -9693,6 +9636,18 @@ create_checkpoint() {
9693
9636
  cp ".loki/$f" "$cp_dir/$f" 2>/dev/null || true
9694
9637
  fi
9695
9638
  done
9639
+ # A6: under a namespaced session the live state file is at
9640
+ # .loki/sessions/<id>/autonomy-state.json, which the legacy loop above misses.
9641
+ # Snapshot it into the checkpoint as autonomy-state.json so a rollback still
9642
+ # captures the current run's machine state. (Default `loki start` has no
9643
+ # LOKI_SESSION_ID, so this block is skipped and the loop above is unchanged.)
9644
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
9645
+ local _ns_state_file
9646
+ _ns_state_file="$(_loki_state_file)"
9647
+ if [ -f "$_ns_state_file" ]; then
9648
+ cp "$_ns_state_file" "$cp_dir/autonomy-state.json" 2>/dev/null || true
9649
+ fi
9650
+ fi
9696
9651
 
9697
9652
  # R6: capture a real working-tree snapshot so code can be truly undone later.
9698
9653
  # Loki does not commit per iteration, so git_sha (HEAD) cannot reconstruct
@@ -9790,6 +9745,47 @@ print(json.dumps({'id':m['id'],'ts':m['timestamp'],'iter':m['iteration'],'task':
9790
9745
  # it without parsing stdout (log_info writes to stdout, so command-substitution
9791
9746
  # capture would include log lines).
9792
9747
  _LAST_CHECKPOINT_ID="$checkpoint_id"
9748
+
9749
+ # A3: best-effort object-store sync of checkpoint state. No-op unless
9750
+ # LOKI_STORAGE_BACKEND is set to a non-local backend. Never blocks/fails the
9751
+ # build on a sync error (the shim swallows errors; we also `|| true`).
9752
+ _loki_object_store_sync_checkpoints || true
9753
+ }
9754
+
9755
+ # A3: push .loki/state/checkpoints/** to the configured object store (best-effort).
9756
+ # Local/unset backend => no-op (zero behavior change for local users). Provider
9757
+ # writes are out of scope; this syncs only the checkpoint state run.sh owns.
9758
+ _loki_object_store_sync_checkpoints() {
9759
+ local backend="${LOKI_STORAGE_BACKEND:-local}"
9760
+ case "$backend" in
9761
+ local|""|file|filesystem) return 0 ;;
9762
+ esac
9763
+ command -v python3 >/dev/null 2>&1 || return 0
9764
+ local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
9765
+ [ -f "$shim" ] || return 0
9766
+ python3 "$shim" sync >/dev/null 2>&1 || log_warn "Object-store checkpoint sync skipped (backend=$backend); build continues."
9767
+ return 0
9768
+ }
9769
+
9770
+ # A3: hydrate .loki/state/checkpoints/** from the object store on a durable
9771
+ # resume when the local volume is empty (best-effort). No-op unless
9772
+ # LOKI_DURABLE_STATE=1 AND a non-local LOKI_STORAGE_BACKEND is set. The shim
9773
+ # itself guards against overwriting a non-empty local volume.
9774
+ _loki_object_store_hydrate_checkpoints() {
9775
+ [ "${LOKI_DURABLE_STATE:-0}" = "1" ] || return 0
9776
+ local backend="${LOKI_STORAGE_BACKEND:-local}"
9777
+ case "$backend" in
9778
+ local|""|file|filesystem) return 0 ;;
9779
+ esac
9780
+ command -v python3 >/dev/null 2>&1 || return 0
9781
+ local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
9782
+ [ -f "$shim" ] || return 0
9783
+ if python3 "$shim" hydrate >/dev/null 2>&1; then
9784
+ log_info "Durable resume: checked object store ($backend) for prior checkpoints."
9785
+ else
9786
+ log_warn "Object-store checkpoint hydrate skipped (backend=$backend); build continues with local state."
9787
+ fi
9788
+ return 0
9793
9789
  }
9794
9790
 
9795
9791
  start_dashboard() {
@@ -11807,16 +11803,39 @@ PYEOF
11807
11803
  # Save/Load Wrapper State
11808
11804
  #===============================================================================
11809
11805
 
11806
+ # A6 (multi-build-per-pod state isolation): resolve the autonomy-state.json path.
11807
+ # The existing .loki/sessions/<id>/ scoping (v6.4.0) namespaces PID + lock files
11808
+ # so concurrent sessions in the SAME TARGET_DIR do not collide. autonomy-state.json
11809
+ # was NOT covered by that scoping, so two concurrent builds with distinct
11810
+ # LOKI_SESSION_IDs still raced on the one global .loki/autonomy-state.json
11811
+ # (lost-update on retry/iteration counts). When LOKI_SESSION_ID is set we mirror
11812
+ # the session scoping and put the file under .loki/sessions/<id>/. When unset
11813
+ # (the normal `loki start ./prd.md` case) the legacy .loki/autonomy-state.json
11814
+ # path is returned UNCHANGED, so default single-session behavior is byte-identical.
11815
+ _loki_state_file() {
11816
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
11817
+ printf '%s' ".loki/sessions/${LOKI_SESSION_ID}/autonomy-state.json"
11818
+ else
11819
+ printf '%s' ".loki/autonomy-state.json"
11820
+ fi
11821
+ }
11822
+
11810
11823
  save_state() {
11811
11824
  local retry_count="$1"
11812
11825
  local status="$2"
11813
11826
  local exit_code="$3"
11814
11827
 
11815
- # BUG-ST-013: Ensure .loki directory exists (defensive -- may be called from signal handler)
11816
- mkdir -p .loki 2>/dev/null || true
11828
+ local state_file
11829
+ state_file="$(_loki_state_file)"
11830
+
11831
+ # BUG-ST-013: Ensure target directory exists (defensive -- may be called from
11832
+ # signal handler). For a namespaced session path this also creates
11833
+ # .loki/sessions/<id>/; for the default path it is .loki/ as before.
11834
+ mkdir -p "$(dirname "$state_file")" 2>/dev/null || true
11817
11835
 
11818
- # BUG-XC-004: Atomic write via temp file + mv
11819
- local state_tmp=".loki/autonomy-state.json.tmp.$$"
11836
+ # BUG-XC-004: Atomic write via temp file + mv (temp lives beside the target so
11837
+ # the rename stays on the same filesystem)
11838
+ local state_tmp="${state_file}.tmp.$$"
11820
11839
  cat > "$state_tmp" << EOF
11821
11840
  {
11822
11841
  "retryCount": $retry_count,
@@ -11830,23 +11849,30 @@ save_state() {
11830
11849
  "baseWait": $BASE_WAIT
11831
11850
  }
11832
11851
  EOF
11833
- mv -f "$state_tmp" ".loki/autonomy-state.json"
11852
+ mv -f "$state_tmp" "$state_file"
11834
11853
  }
11835
11854
 
11836
11855
  load_state() {
11856
+ local state_file
11857
+ state_file="$(_loki_state_file)"
11858
+
11837
11859
  # BUG-EP-015: Clean up orphaned temp files from kill -9 crashes
11838
11860
  # These are left behind when the process is killed during atomic writes
11839
11861
  find .loki/ -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11840
11862
  find .loki/state/ -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11863
+ # A6: also sweep namespaced session state temps when running under a session id
11864
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
11865
+ find ".loki/sessions/${LOKI_SESSION_ID}/" -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11866
+ fi
11841
11867
 
11842
- if [ -f ".loki/autonomy-state.json" ]; then
11868
+ if [ -f "$state_file" ]; then
11843
11869
  if command -v python3 &> /dev/null; then
11844
11870
  # BUG-ST-006: Validate checkpoint integrity before loading state
11845
11871
  local state_valid
11846
- state_valid=$(python3 -c "
11847
- import json, sys
11872
+ state_valid=$(LOKI_STATE_FILE="$state_file" python3 -c "
11873
+ import json, os, sys
11848
11874
  try:
11849
- with open('.loki/autonomy-state.json') as f:
11875
+ with open(os.environ['LOKI_STATE_FILE']) as f:
11850
11876
  d = json.load(f)
11851
11877
  # Validate required fields exist and have sane types
11852
11878
  rc = d.get('retryCount', 0)
@@ -11868,16 +11894,16 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
11868
11894
  RETRY_COUNT=0
11869
11895
  ITERATION_COUNT=0
11870
11896
  # Back up corrupted state file for diagnosis
11871
- mv ".loki/autonomy-state.json" ".loki/autonomy-state.json.corrupt.$(date +%s)" 2>/dev/null || true
11897
+ mv "$state_file" "${state_file}.corrupt.$(date +%s)" 2>/dev/null || true
11872
11898
  return
11873
11899
  fi
11874
11900
 
11875
11901
  # Load retry count, iteration count, and status from previous session
11876
11902
  local prev_status
11877
- prev_status=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
11878
- RETRY_COUNT=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('retryCount', 0))" 2>/dev/null || echo "0")
11903
+ prev_status=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
11904
+ RETRY_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('retryCount', 0))" 2>/dev/null || echo "0")
11879
11905
  # BUG-RUN-003: Restore ITERATION_COUNT from persisted state
11880
- ITERATION_COUNT=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('iterationCount', 0))" 2>/dev/null || echo "0")
11906
+ ITERATION_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('iterationCount', 0))" 2>/dev/null || echo "0")
11881
11907
 
11882
11908
  # Reset retry count + iteration count if previous session ended in a
11883
11909
  # terminal state. A fresh `loki start` after a terminal run is a NEW
@@ -13979,7 +14005,7 @@ except Exception as exc:
13979
14005
 
13980
14006
  # Check max iterations before starting
13981
14007
  if check_max_iterations; then
13982
- log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json"
14008
+ log_error "Max iterations already reached. Reset with: rm $(_loki_state_file)"
13983
14009
  # Delegate-then-notify: terminal state. Mirror the in-loop max-iterations
13984
14010
  # site so a detached (--bg) run still writes COMPLETION.txt + fires the
13985
14011
  # ping on this pre-loop exit. _LOKI_RUN_START_SHA is already exported
@@ -16818,6 +16844,14 @@ main() {
16818
16844
  # durable mount, so a misconfigured volume never silently loses build state.
16819
16845
  assert_durable_state_mount
16820
16846
 
16847
+ # A3: on a durable resume with an object-store backend, hydrate this run's
16848
+ # checkpoints from the store IF the local volume came up empty (a fresh pod /
16849
+ # non-durable volume). Best-effort + idempotent: the shim no-ops when the
16850
+ # backend is local, when the local volume already has checkpoints, or when
16851
+ # the store has nothing for this run. Runs before the main loop so any
16852
+ # resume/rollback sees the restored state. Zero behavior change for local.
16853
+ _loki_object_store_hydrate_checkpoints || true
16854
+
16821
16855
  # Setup agent branch protection (isolates agent changes to a feature branch)
16822
16856
  setup_agent_branch
16823
16857
 
@@ -17035,8 +17069,9 @@ main() {
17035
17069
  # because the process was SIGKILLed before reaching here). Retryable;
17036
17070
  # the restarted Job resumes via the ENT-2 durable-resume path.
17037
17071
  if [ "${LOKI_DURABLE_STATE:-0}" = "1" ]; then
17038
- local _final_status
17039
- _final_status=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('status','unknown'))" 2>/dev/null || echo "unknown")
17072
+ local _final_status _final_state_file
17073
+ _final_state_file="$(_loki_state_file)"
17074
+ _final_status=$(LOKI_STATE_FILE="$_final_state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status','unknown'))" 2>/dev/null || echo "unknown")
17040
17075
  case "$_final_status" in
17041
17076
  council_approved|council_force_approved|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
17042
17077
  result=0 ;;