loki-mode 7.78.0 → 7.79.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
@@ -6275,6 +6193,28 @@ check_command_allowed() {
6275
6193
  return 0
6276
6194
  }
6277
6195
 
6196
+ # A5 (ALLOWED_PATHS enforcement) -- HONEST THREAT MODEL
6197
+ # --------------------------------------------------------------------------
6198
+ # The DOMINANT file-write path in an autonomous build is the PROVIDER CLI
6199
+ # (claude / codex / cline / aider) writing files directly. run.sh hands the
6200
+ # work to that CLI and never sees those writes as shell redirections, so run.sh
6201
+ # CANNOT intercept provider-driven writes. ALLOWED_PATHS therefore cannot be a
6202
+ # complete sandbox; the real containment for provider writes is the OS user +
6203
+ # the LOKI_SANDBOX_MODE container (sandbox.sh, cap-drop/seccomp/read-only
6204
+ # mounts), not this shell.
6205
+ #
6206
+ # Where ALLOWED_PATHS is actually enforced: the sandbox custom --mount surface.
6207
+ # That enforcement lives in sandbox.sh, NOT here. When ALLOWED_PATHS is set, a
6208
+ # host path that loki is about to bind-mount into the container is checked by
6209
+ # _sandbox_path_within_allowed (autonomy/sandbox.sh), and an operator-supplied
6210
+ # `loki sandbox run` argv is checked by _sandbox_command_allowed there too.
6211
+ # run.sh itself does not consume ALLOWED_PATHS for any write decision -- it has
6212
+ # no run.sh-controlled host-write surface to gate -- so there is no enforcement
6213
+ # helper here. (A run.sh-local helper existed previously but had zero call
6214
+ # sites; it was removed so the only enforcement path is the one that genuinely
6215
+ # runs. See tests/test-allowed-paths-a5.sh, which exercises the sandbox.sh
6216
+ # functions.)
6217
+
6278
6218
  #===============================================================================
6279
6219
  # Cross-Project Learnings Database
6280
6220
  #===============================================================================
@@ -9693,6 +9633,18 @@ create_checkpoint() {
9693
9633
  cp ".loki/$f" "$cp_dir/$f" 2>/dev/null || true
9694
9634
  fi
9695
9635
  done
9636
+ # A6: under a namespaced session the live state file is at
9637
+ # .loki/sessions/<id>/autonomy-state.json, which the legacy loop above misses.
9638
+ # Snapshot it into the checkpoint as autonomy-state.json so a rollback still
9639
+ # captures the current run's machine state. (Default `loki start` has no
9640
+ # LOKI_SESSION_ID, so this block is skipped and the loop above is unchanged.)
9641
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
9642
+ local _ns_state_file
9643
+ _ns_state_file="$(_loki_state_file)"
9644
+ if [ -f "$_ns_state_file" ]; then
9645
+ cp "$_ns_state_file" "$cp_dir/autonomy-state.json" 2>/dev/null || true
9646
+ fi
9647
+ fi
9696
9648
 
9697
9649
  # R6: capture a real working-tree snapshot so code can be truly undone later.
9698
9650
  # Loki does not commit per iteration, so git_sha (HEAD) cannot reconstruct
@@ -9790,6 +9742,47 @@ print(json.dumps({'id':m['id'],'ts':m['timestamp'],'iter':m['iteration'],'task':
9790
9742
  # it without parsing stdout (log_info writes to stdout, so command-substitution
9791
9743
  # capture would include log lines).
9792
9744
  _LAST_CHECKPOINT_ID="$checkpoint_id"
9745
+
9746
+ # A3: best-effort object-store sync of checkpoint state. No-op unless
9747
+ # LOKI_STORAGE_BACKEND is set to a non-local backend. Never blocks/fails the
9748
+ # build on a sync error (the shim swallows errors; we also `|| true`).
9749
+ _loki_object_store_sync_checkpoints || true
9750
+ }
9751
+
9752
+ # A3: push .loki/state/checkpoints/** to the configured object store (best-effort).
9753
+ # Local/unset backend => no-op (zero behavior change for local users). Provider
9754
+ # writes are out of scope; this syncs only the checkpoint state run.sh owns.
9755
+ _loki_object_store_sync_checkpoints() {
9756
+ local backend="${LOKI_STORAGE_BACKEND:-local}"
9757
+ case "$backend" in
9758
+ local|""|file|filesystem) return 0 ;;
9759
+ esac
9760
+ command -v python3 >/dev/null 2>&1 || return 0
9761
+ local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
9762
+ [ -f "$shim" ] || return 0
9763
+ python3 "$shim" sync >/dev/null 2>&1 || log_warn "Object-store checkpoint sync skipped (backend=$backend); build continues."
9764
+ return 0
9765
+ }
9766
+
9767
+ # A3: hydrate .loki/state/checkpoints/** from the object store on a durable
9768
+ # resume when the local volume is empty (best-effort). No-op unless
9769
+ # LOKI_DURABLE_STATE=1 AND a non-local LOKI_STORAGE_BACKEND is set. The shim
9770
+ # itself guards against overwriting a non-empty local volume.
9771
+ _loki_object_store_hydrate_checkpoints() {
9772
+ [ "${LOKI_DURABLE_STATE:-0}" = "1" ] || return 0
9773
+ local backend="${LOKI_STORAGE_BACKEND:-local}"
9774
+ case "$backend" in
9775
+ local|""|file|filesystem) return 0 ;;
9776
+ esac
9777
+ command -v python3 >/dev/null 2>&1 || return 0
9778
+ local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
9779
+ [ -f "$shim" ] || return 0
9780
+ if python3 "$shim" hydrate >/dev/null 2>&1; then
9781
+ log_info "Durable resume: checked object store ($backend) for prior checkpoints."
9782
+ else
9783
+ log_warn "Object-store checkpoint hydrate skipped (backend=$backend); build continues with local state."
9784
+ fi
9785
+ return 0
9793
9786
  }
9794
9787
 
9795
9788
  start_dashboard() {
@@ -11807,16 +11800,39 @@ PYEOF
11807
11800
  # Save/Load Wrapper State
11808
11801
  #===============================================================================
11809
11802
 
11803
+ # A6 (multi-build-per-pod state isolation): resolve the autonomy-state.json path.
11804
+ # The existing .loki/sessions/<id>/ scoping (v6.4.0) namespaces PID + lock files
11805
+ # so concurrent sessions in the SAME TARGET_DIR do not collide. autonomy-state.json
11806
+ # was NOT covered by that scoping, so two concurrent builds with distinct
11807
+ # LOKI_SESSION_IDs still raced on the one global .loki/autonomy-state.json
11808
+ # (lost-update on retry/iteration counts). When LOKI_SESSION_ID is set we mirror
11809
+ # the session scoping and put the file under .loki/sessions/<id>/. When unset
11810
+ # (the normal `loki start ./prd.md` case) the legacy .loki/autonomy-state.json
11811
+ # path is returned UNCHANGED, so default single-session behavior is byte-identical.
11812
+ _loki_state_file() {
11813
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
11814
+ printf '%s' ".loki/sessions/${LOKI_SESSION_ID}/autonomy-state.json"
11815
+ else
11816
+ printf '%s' ".loki/autonomy-state.json"
11817
+ fi
11818
+ }
11819
+
11810
11820
  save_state() {
11811
11821
  local retry_count="$1"
11812
11822
  local status="$2"
11813
11823
  local exit_code="$3"
11814
11824
 
11815
- # BUG-ST-013: Ensure .loki directory exists (defensive -- may be called from signal handler)
11816
- mkdir -p .loki 2>/dev/null || true
11825
+ local state_file
11826
+ state_file="$(_loki_state_file)"
11827
+
11828
+ # BUG-ST-013: Ensure target directory exists (defensive -- may be called from
11829
+ # signal handler). For a namespaced session path this also creates
11830
+ # .loki/sessions/<id>/; for the default path it is .loki/ as before.
11831
+ mkdir -p "$(dirname "$state_file")" 2>/dev/null || true
11817
11832
 
11818
- # BUG-XC-004: Atomic write via temp file + mv
11819
- local state_tmp=".loki/autonomy-state.json.tmp.$$"
11833
+ # BUG-XC-004: Atomic write via temp file + mv (temp lives beside the target so
11834
+ # the rename stays on the same filesystem)
11835
+ local state_tmp="${state_file}.tmp.$$"
11820
11836
  cat > "$state_tmp" << EOF
11821
11837
  {
11822
11838
  "retryCount": $retry_count,
@@ -11830,23 +11846,30 @@ save_state() {
11830
11846
  "baseWait": $BASE_WAIT
11831
11847
  }
11832
11848
  EOF
11833
- mv -f "$state_tmp" ".loki/autonomy-state.json"
11849
+ mv -f "$state_tmp" "$state_file"
11834
11850
  }
11835
11851
 
11836
11852
  load_state() {
11853
+ local state_file
11854
+ state_file="$(_loki_state_file)"
11855
+
11837
11856
  # BUG-EP-015: Clean up orphaned temp files from kill -9 crashes
11838
11857
  # These are left behind when the process is killed during atomic writes
11839
11858
  find .loki/ -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11840
11859
  find .loki/state/ -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11860
+ # A6: also sweep namespaced session state temps when running under a session id
11861
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
11862
+ find ".loki/sessions/${LOKI_SESSION_ID}/" -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11863
+ fi
11841
11864
 
11842
- if [ -f ".loki/autonomy-state.json" ]; then
11865
+ if [ -f "$state_file" ]; then
11843
11866
  if command -v python3 &> /dev/null; then
11844
11867
  # BUG-ST-006: Validate checkpoint integrity before loading state
11845
11868
  local state_valid
11846
- state_valid=$(python3 -c "
11847
- import json, sys
11869
+ state_valid=$(LOKI_STATE_FILE="$state_file" python3 -c "
11870
+ import json, os, sys
11848
11871
  try:
11849
- with open('.loki/autonomy-state.json') as f:
11872
+ with open(os.environ['LOKI_STATE_FILE']) as f:
11850
11873
  d = json.load(f)
11851
11874
  # Validate required fields exist and have sane types
11852
11875
  rc = d.get('retryCount', 0)
@@ -11868,16 +11891,16 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
11868
11891
  RETRY_COUNT=0
11869
11892
  ITERATION_COUNT=0
11870
11893
  # Back up corrupted state file for diagnosis
11871
- mv ".loki/autonomy-state.json" ".loki/autonomy-state.json.corrupt.$(date +%s)" 2>/dev/null || true
11894
+ mv "$state_file" "${state_file}.corrupt.$(date +%s)" 2>/dev/null || true
11872
11895
  return
11873
11896
  fi
11874
11897
 
11875
11898
  # Load retry count, iteration count, and status from previous session
11876
11899
  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")
11900
+ 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")
11901
+ 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
11902
  # 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")
11903
+ 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
11904
 
11882
11905
  # Reset retry count + iteration count if previous session ended in a
11883
11906
  # terminal state. A fresh `loki start` after a terminal run is a NEW
@@ -13979,7 +14002,7 @@ except Exception as exc:
13979
14002
 
13980
14003
  # Check max iterations before starting
13981
14004
  if check_max_iterations; then
13982
- log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json"
14005
+ log_error "Max iterations already reached. Reset with: rm $(_loki_state_file)"
13983
14006
  # Delegate-then-notify: terminal state. Mirror the in-loop max-iterations
13984
14007
  # site so a detached (--bg) run still writes COMPLETION.txt + fires the
13985
14008
  # ping on this pre-loop exit. _LOKI_RUN_START_SHA is already exported
@@ -16818,6 +16841,14 @@ main() {
16818
16841
  # durable mount, so a misconfigured volume never silently loses build state.
16819
16842
  assert_durable_state_mount
16820
16843
 
16844
+ # A3: on a durable resume with an object-store backend, hydrate this run's
16845
+ # checkpoints from the store IF the local volume came up empty (a fresh pod /
16846
+ # non-durable volume). Best-effort + idempotent: the shim no-ops when the
16847
+ # backend is local, when the local volume already has checkpoints, or when
16848
+ # the store has nothing for this run. Runs before the main loop so any
16849
+ # resume/rollback sees the restored state. Zero behavior change for local.
16850
+ _loki_object_store_hydrate_checkpoints || true
16851
+
16821
16852
  # Setup agent branch protection (isolates agent changes to a feature branch)
16822
16853
  setup_agent_branch
16823
16854
 
@@ -17035,8 +17066,9 @@ main() {
17035
17066
  # because the process was SIGKILLed before reaching here). Retryable;
17036
17067
  # the restarted Job resumes via the ENT-2 durable-resume path.
17037
17068
  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")
17069
+ local _final_status _final_state_file
17070
+ _final_state_file="$(_loki_state_file)"
17071
+ _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
17072
  case "$_final_status" in
17041
17073
  council_approved|council_force_approved|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
17042
17074
  result=0 ;;