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.
@@ -0,0 +1,955 @@
1
+ #!/usr/bin/env bash
2
+ # config-map.sh -- single-source config mapping + config-file loader (FEAT-CONFIG, #691)
3
+ #
4
+ # This library is the ONE home of the YAML/config-file key mapping and the
5
+ # parse-and-export logic. It is sourced by BOTH:
6
+ # - autonomy/loki main() pre-pass (loki_maybe_apply_config_file, override=1)
7
+ # - autonomy/run.sh YAML parsers (loki_config_export_key, override=0)
8
+ # collapsing the formerly-3 tables (parse_simple_yaml, parse_yaml_with_yq,
9
+ # config.example.yaml) to ONE canonical array.
10
+ #
11
+ # DESIGN CONSTRAINT: SIDE-EFFECT-FREE ON SOURCE. Sourcing this file only DEFINES
12
+ # the array + functions. It exports nothing and runs nothing. Callers invoke the
13
+ # functions explicitly. This lets unit tests source the lib and exercise the
14
+ # parser / expander / matcher in isolation.
15
+ #
16
+ # Precedence (the keystone): an `override` parameter on the shared per-key export
17
+ # decides config-vs-env. override=1 (pre-pass) -> config BEATS ambient env;
18
+ # override=0 (run.sh auto-discovery) -> ambient env wins (shipped contract).
19
+
20
+ # Double-source guard. Re-sourcing must not redefine or re-cost anything.
21
+ if [ -n "${_LOKI_CONFIG_MAP_SOURCED:-}" ]; then
22
+ return 0 2>/dev/null || true
23
+ fi
24
+ _LOKI_CONFIG_MAP_SOURCED=1
25
+
26
+ #===============================================================================
27
+ # 2a. Canonical mapping array (64 keys)
28
+ #
29
+ # Copied verbatim from parse_simple_yaml's set_from_yaml calls (run.sh:271-354)
30
+ # so env-var names are guaranteed correct. model.compaction_interval is EXCLUDED
31
+ # (no runtime consumer). This is the YAML/config-file surface ONLY; the
32
+ # settings.json schema (_load_json_settings) stays separate by design.
33
+ #===============================================================================
34
+ LOKI_CONFIG_MAP=(
35
+ # core
36
+ "core.max_retries:LOKI_MAX_RETRIES"
37
+ "core.base_wait:LOKI_BASE_WAIT"
38
+ "core.max_wait:LOKI_MAX_WAIT"
39
+ "core.skip_prereqs:LOKI_SKIP_PREREQS"
40
+ # dashboard
41
+ "dashboard.enabled:LOKI_DASHBOARD"
42
+ "dashboard.port:LOKI_DASHBOARD_PORT"
43
+ # resources
44
+ "resources.check_interval:LOKI_RESOURCE_CHECK_INTERVAL"
45
+ "resources.cpu_threshold:LOKI_RESOURCE_CPU_THRESHOLD"
46
+ "resources.mem_threshold:LOKI_RESOURCE_MEM_THRESHOLD"
47
+ # security
48
+ "security.staged_autonomy:LOKI_STAGED_AUTONOMY"
49
+ "security.audit_log:LOKI_AUDIT_LOG"
50
+ "security.max_parallel_agents:LOKI_MAX_PARALLEL_AGENTS"
51
+ "security.sandbox_mode:LOKI_SANDBOX_MODE"
52
+ "security.allowed_paths:LOKI_ALLOWED_PATHS"
53
+ "security.blocked_commands:LOKI_BLOCKED_COMMANDS"
54
+ # phases
55
+ "phases.unit_tests:LOKI_PHASE_UNIT_TESTS"
56
+ "phases.api_tests:LOKI_PHASE_API_TESTS"
57
+ "phases.e2e_tests:LOKI_PHASE_E2E_TESTS"
58
+ "phases.security:LOKI_PHASE_SECURITY"
59
+ "phases.integration:LOKI_PHASE_INTEGRATION"
60
+ "phases.code_review:LOKI_PHASE_CODE_REVIEW"
61
+ "phases.web_research:LOKI_PHASE_WEB_RESEARCH"
62
+ "phases.performance:LOKI_PHASE_PERFORMANCE"
63
+ "phases.accessibility:LOKI_PHASE_ACCESSIBILITY"
64
+ "phases.regression:LOKI_PHASE_REGRESSION"
65
+ "phases.uat:LOKI_PHASE_UAT"
66
+ # completion
67
+ "completion.promise:LOKI_COMPLETION_PROMISE"
68
+ "completion.max_iterations:LOKI_MAX_ITERATIONS"
69
+ "completion.perpetual_mode:LOKI_PERPETUAL_MODE"
70
+ # completion.council
71
+ "completion.council.enabled:LOKI_COUNCIL_ENABLED"
72
+ "completion.council.size:LOKI_COUNCIL_SIZE"
73
+ "completion.council.threshold:LOKI_COUNCIL_THRESHOLD"
74
+ "completion.council.check_interval:LOKI_COUNCIL_CHECK_INTERVAL"
75
+ "completion.council.min_iterations:LOKI_COUNCIL_MIN_ITERATIONS"
76
+ "completion.council.stagnation_limit:LOKI_COUNCIL_STAGNATION_LIMIT"
77
+ # completion.uncertainty
78
+ "completion.uncertainty.escalation:LOKI_UNCERTAINTY_ESCALATION"
79
+ "completion.uncertainty.rounds:LOKI_UNCERTAINTY_ROUNDS"
80
+ "completion.uncertainty.nochange_min:LOKI_UNCERTAINTY_NOCHANGE_MIN"
81
+ "completion.uncertainty.split_rounds:LOKI_UNCERTAINTY_SPLIT_ROUNDS"
82
+ # model (model.planning/development/fast are T1-only; verified env vars)
83
+ "model.prompt_repetition:LOKI_PROMPT_REPETITION"
84
+ "model.confidence_routing:LOKI_CONFIDENCE_ROUTING"
85
+ "model.autonomy_mode:LOKI_AUTONOMY_MODE"
86
+ "model.planning:LOKI_MODEL_PLANNING"
87
+ "model.development:LOKI_MODEL_DEVELOPMENT"
88
+ "model.fast:LOKI_MODEL_FAST"
89
+ # parallel
90
+ "parallel.enabled:LOKI_PARALLEL_MODE"
91
+ "parallel.max_worktrees:LOKI_MAX_WORKTREES"
92
+ "parallel.max_sessions:LOKI_MAX_PARALLEL_SESSIONS"
93
+ "parallel.testing:LOKI_PARALLEL_TESTING"
94
+ "parallel.docs:LOKI_PARALLEL_DOCS"
95
+ "parallel.blog:LOKI_PARALLEL_BLOG"
96
+ "parallel.auto_merge:LOKI_AUTO_MERGE"
97
+ # complexity
98
+ "complexity.tier:LOKI_COMPLEXITY"
99
+ # github
100
+ "github.import:LOKI_GITHUB_IMPORT"
101
+ "github.pr:LOKI_GITHUB_PR"
102
+ "github.sync:LOKI_GITHUB_SYNC"
103
+ "github.repo:LOKI_GITHUB_REPO"
104
+ "github.labels:LOKI_GITHUB_LABELS"
105
+ "github.milestone:LOKI_GITHUB_MILESTONE"
106
+ "github.assignee:LOKI_GITHUB_ASSIGNEE"
107
+ "github.limit:LOKI_GITHUB_LIMIT"
108
+ "github.pr_label:LOKI_GITHUB_PR_LABEL"
109
+ # notifications
110
+ "notifications.enabled:LOKI_NOTIFICATIONS"
111
+ "notifications.sound:LOKI_NOTIFICATION_SOUND"
112
+ )
113
+
114
+ # Annotations for `config example|schema` generation. Maps a nested.path to a
115
+ # one-line human comment. Only used by the generators; absence is harmless.
116
+ LOKI_CONFIG_COMMENTS=(
117
+ "core.max_retries:Max retry attempts for rate limits and transient failures"
118
+ "core.base_wait:Base wait time in seconds for exponential backoff"
119
+ "core.max_wait:Maximum wait time in seconds"
120
+ "core.skip_prereqs:Skip prerequisite checks (not recommended)"
121
+ "dashboard.enabled:Enable web dashboard"
122
+ "dashboard.port:Dashboard port"
123
+ "resources.check_interval:Check resources every N seconds"
124
+ "resources.cpu_threshold:CPU percentage threshold to warn"
125
+ "resources.mem_threshold:Memory percentage threshold to warn"
126
+ "security.staged_autonomy:Require approval before execution (staged autonomy)"
127
+ "security.audit_log:Enable audit logging"
128
+ "security.max_parallel_agents:Limit concurrent agent spawning"
129
+ "security.sandbox_mode:Run in sandboxed container (requires Docker)"
130
+ "security.allowed_paths:Comma-separated paths agents can modify (empty = all)"
131
+ "security.blocked_commands:Comma-separated blocked shell commands"
132
+ "phases.unit_tests:Enable unit test phase"
133
+ "phases.api_tests:Enable API test phase"
134
+ "phases.e2e_tests:Enable end-to-end test phase"
135
+ "phases.security:Enable security phase"
136
+ "phases.integration:Enable integration phase"
137
+ "phases.code_review:Enable code review phase"
138
+ "phases.web_research:Enable web research phase"
139
+ "phases.performance:Enable performance phase"
140
+ "phases.accessibility:Enable accessibility phase"
141
+ "phases.regression:Enable regression phase"
142
+ "phases.uat:Enable UAT phase"
143
+ "completion.promise:Explicit stop condition text (empty = runs until stopped)"
144
+ "completion.max_iterations:Max loop iterations before exit"
145
+ "completion.perpetual_mode:Ignore ALL completion signals (runs forever)"
146
+ "completion.council.enabled:Enable completion council voting"
147
+ "completion.council.size:Number of council reviewers"
148
+ "completion.council.threshold:Approval threshold for council stop"
149
+ "completion.council.check_interval:Run the council every N iterations"
150
+ "completion.council.min_iterations:Minimum iterations before council can stop"
151
+ "completion.council.stagnation_limit:No-change rounds before circuit-breaker"
152
+ "completion.uncertainty.escalation:Uncertainty-gated escalation toggle (0/1)"
153
+ "completion.uncertainty.rounds:Consecutive rounds before escalating"
154
+ "completion.uncertainty.nochange_min:No-change proxy threshold"
155
+ "completion.uncertainty.split_rounds:Council-split proxy threshold"
156
+ "model.prompt_repetition:Enable prompt repetition for Haiku agents"
157
+ "model.confidence_routing:Enable confidence-based routing"
158
+ "model.autonomy_mode:Autonomy level: perpetual, checkpoint, or supervised"
159
+ "model.planning:Model for the planning tier"
160
+ "model.development:Model for the development tier"
161
+ "model.fast:Model for the fast tier"
162
+ "parallel.enabled:Enable git worktree-based parallelism"
163
+ "parallel.max_worktrees:Maximum parallel worktrees"
164
+ "parallel.max_sessions:Maximum concurrent sessions"
165
+ "parallel.testing:Run testing stream in parallel"
166
+ "parallel.docs:Run documentation stream in parallel"
167
+ "parallel.blog:Run blog stream if site has blog"
168
+ "parallel.auto_merge:Auto-merge completed features"
169
+ "complexity.tier:Force complexity tier: auto, simple, moderate, complex, enterprise"
170
+ "github.import:Import open issues as tasks"
171
+ "github.pr:Create PR when feature complete"
172
+ "github.sync:Sync status back to issues"
173
+ "github.repo:Override repo detection (owner/repo)"
174
+ "github.labels:Filter by labels (comma-separated)"
175
+ "github.milestone:Filter by milestone"
176
+ "github.assignee:Filter by assignee"
177
+ "github.limit:Max issues to import"
178
+ "github.pr_label:Label for PRs (empty = no label)"
179
+ "notifications.enabled:Enable desktop notifications"
180
+ "notifications.sound:Play sound with notifications"
181
+ )
182
+
183
+ #===============================================================================
184
+ # 2b. Known-env-var set for the .env-format key allowlist.
185
+ #
186
+ # YAML/JSON are bound to LOKI_CONFIG_MAP (only mapped nested.path keys are read),
187
+ # so a typo or unadvertised key in those formats is simply ignored. The .env
188
+ # format reads keys directly, so without a membership check ANY LOKI_* key would
189
+ # be accepted -- a typo (LOKI_MAX_RETRIE) or unadvertised key would silently pass
190
+ # `config validate` and (for the loader) be exported. To bring .env to parity, an
191
+ # .env LOKI_ key is accepted only if it is either:
192
+ # (a) a config-map env var ("${LOKI_CONFIG_MAP[@]##*:}"), OR
193
+ # (b) listed in LOKI_ENV_EXTRA_ALLOWLIST below.
194
+ #
195
+ # (b) holds the LOKI_* vars that are NOT in the config map but have a real
196
+ # runtime consumer and are legitimately set via .env in enterprise/durable
197
+ # deployments. Each entry below cites its consumer; do NOT add a var here without
198
+ # one.
199
+ LOKI_ENV_EXTRA_ALLOWLIST=(
200
+ LOKI_DURABLE_STATE # run.sh:9583 (durable-state opt-in)
201
+ LOKI_STORAGE_BACKEND # checkpoint_sync.py:48, lokistore/factory.py:51
202
+ LOKI_STORAGE_BUCKET # lokistore/factory.py:54
203
+ LOKI_STORAGE_PREFIX # lokistore/factory.py:57
204
+ LOKI_STORAGE_REGION # lokistore/factory.py:60
205
+ LOKI_METADATA_BACKEND # lokistore/factory.py:164
206
+ LOKI_METADATA_URL # lokistore/factory.py:181
207
+ LOKI_DATA_DIR # lokistore/factory.py:169
208
+ LOKI_RUN_ID # checkpoint_sync.py:56 (per-run object-store namespace)
209
+ LOKI_SESSION_ID # checkpoint_sync.py:56, run.sh session paths
210
+ LOKI_DIR # checkpoint_sync.py:61, lokistore/factory.py:41
211
+ LOKI_BUDGET_LIMIT # run.sh:484 (cost cap)
212
+ LOKI_PROVIDER # run.sh:837 (provider select)
213
+ LOKI_ISSUE_PROVIDER # run.sh:455 settings mapping (issue provider)
214
+ LOKI_MAX_TIER # run.sh:448 settings mapping (model tier cap)
215
+ LOKI_SLACK_WEBHOOK # run.sh:452 settings mapping (notify)
216
+ LOKI_DISCORD_WEBHOOK # run.sh:453 settings mapping (notify)
217
+ LOKI_BLIND_VALIDATION # run.sh:456 settings mapping
218
+ LOKI_ADVERSARIAL_TESTING # run.sh:457 settings mapping
219
+ LOKI_SPAWN_TIMEOUT # run.sh:458 settings mapping
220
+ LOKI_SPAWN_RETRIES # run.sh:459 settings mapping
221
+ )
222
+
223
+ # Return 0 if an .env LOKI_ key is a recognized var (config-map member OR an
224
+ # explicitly-allowlisted non-map var with a real consumer), else 1.
225
+ loki_env_key_is_known() {
226
+ local key="$1"
227
+ local mapping extra
228
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
229
+ [ "${mapping##*:}" = "$key" ] && return 0
230
+ done
231
+ for extra in "${LOKI_ENV_EXTRA_ALLOWLIST[@]}"; do
232
+ [ "$extra" = "$key" ] && return 0
233
+ done
234
+ return 1
235
+ }
236
+
237
+ #===============================================================================
238
+ # Self-contained validators (the loki process has no run.sh validate_yaml_value)
239
+ #===============================================================================
240
+
241
+ # Validate a resolved config value before export. Rejects empty, over-length,
242
+ # newlines, and shell metacharacters.
243
+ #
244
+ # NOTE: this intentionally does NOT reuse run.sh's validate_yaml_value regex
245
+ # verbatim. That shipped regex (run.sh:358, a bash `[[ =~ [\$\`...] ]]` bracket
246
+ # expression) silently matches NOTHING -- the backslash-escapes inside the
247
+ # character class are taken literally, so it never rejects a metachar. On this
248
+ # NEW security-sensitive config-file surface, the injection contract (#691 SS7
249
+ # case 8) requires real rejection, so we use a `case` glob character class,
250
+ # which bash evaluates correctly. The allow-set mirrors validate_yaml_value's
251
+ # documented intent: alphanumerics plus spaces, dots, dashes, underscores,
252
+ # slashes, colons, commas, @.
253
+ loki_validate_value() {
254
+ local value="$1"
255
+ local max_length="${2:-1000}"
256
+
257
+ # Reject empty values
258
+ if [ -z "$value" ]; then
259
+ return 1
260
+ fi
261
+
262
+ # Reject values with dangerous shell metacharacters: $ ` | ; & < > ( ) { }
263
+ # [ ] and backslash. (case glob bracket class -- evaluated reliably, unlike
264
+ # the shipped =~ bracket regex.)
265
+ # NOTE: single-quote and double-quote are NOT in the reject set, so a value
266
+ # containing a quote passes. That is intentional and benign here: config
267
+ # values are only ever quoted-exported (loki_config_export_key) or printed,
268
+ # never eval'd, so an embedded quote cannot break out into a command. The
269
+ # set above blocks the chars that WOULD matter for command/expansion
270
+ # injection on the surfaces that consume these values.
271
+ case "$value" in
272
+ *['$`|;&<>(){}[]\']*) return 1 ;;
273
+ esac
274
+
275
+ # Reject values that are too long (DoS protection)
276
+ if [ "${#value}" -gt "$max_length" ]; then
277
+ return 1
278
+ fi
279
+
280
+ # Reject values with newlines (could corrupt variables)
281
+ if [[ "$value" == *$'\n'* ]]; then
282
+ return 1
283
+ fi
284
+
285
+ return 0
286
+ }
287
+
288
+ # Mirror of run.sh escape_regex (run.sh:387) for the grep/sed YAML fallback.
289
+ loki_escape_regex() {
290
+ local input="$1"
291
+ printf '%s' "$input" | sed 's/[.[\*?+^${}|()\\]/\\&/g'
292
+ }
293
+
294
+ #===============================================================================
295
+ # 5a. ${VAR} env-ref expansion (NEVER eval)
296
+ #===============================================================================
297
+ # Expand full-value and embedded ${NAME} references via bash indirect expansion.
298
+ # Order is EXPAND-THEN-VALIDATE (validate rejects '$', so an unexpanded ${VAR}
299
+ # would always fail). An unset reference makes expansion FAIL (return non-zero)
300
+ # so the caller can skip the key and warn; it never exports an empty value.
301
+ #
302
+ # Echoes the expanded value on success. Returns 1 (and echoes the unresolved
303
+ # var name on stderr is the caller's job) when any referenced var is unset.
304
+ loki_expand_refs() {
305
+ local value="$1"
306
+ # Fast path: no ${...} at all.
307
+ if [[ "$value" != *'${'* ]]; then
308
+ printf '%s' "$value"
309
+ return 0
310
+ fi
311
+ local out=""
312
+ local rest="$value"
313
+ local prefix name resolved
314
+ while [[ "$rest" == *'${'* ]]; do
315
+ # literal text before the next ${
316
+ prefix="${rest%%\$\{*}"
317
+ out+="$prefix"
318
+ rest="${rest#"$prefix"}" # rest now starts with ${
319
+ rest="${rest#\$\{}" # strip leading ${
320
+ if [[ "$rest" != *'}'* ]]; then
321
+ # Unterminated ${ -- treat the remainder as literal and stop.
322
+ out+='${'
323
+ out+="$rest"
324
+ rest=""
325
+ break
326
+ fi
327
+ name="${rest%%\}*}" # name up to first }
328
+ rest="${rest#*\}}" # remainder after }
329
+ # Only [A-Za-z_][A-Za-z0-9_]* are valid var names; anything else is literal.
330
+ if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
331
+ if [ -z "${!name+x}" ]; then
332
+ # Unset reference -> signal failure with the offending name.
333
+ printf '%s' "$name"
334
+ return 1
335
+ fi
336
+ resolved="${!name}"
337
+ out+="$resolved"
338
+ else
339
+ # Not a valid name -- preserve literally.
340
+ out+='${'
341
+ out+="$name"
342
+ out+='}'
343
+ fi
344
+ done
345
+ out+="$rest"
346
+ printf '%s' "$out"
347
+ return 0
348
+ }
349
+
350
+ #===============================================================================
351
+ # 2a-0. The keystone: shared per-key export with override-mode precedence
352
+ #===============================================================================
353
+ # loki_config_export_key <env_var> <value> <override>
354
+ # override != 1 AND env already set -> return 0 (env-wins guard; run.sh path)
355
+ # else: ${VAR}-expand -> validate -> export
356
+ # Returns non-zero (without exporting) on unresolved ref or validation failure,
357
+ # so callers can warn. A "null"/empty post-strip value is skipped silently
358
+ # (matches run.sh's existing behavior).
359
+ loki_config_export_key() {
360
+ local env_var="$1"
361
+ local value="$2"
362
+ local override="${3:-0}"
363
+
364
+ # env-wins guard (only when NOT overriding)
365
+ if [ "$override" != "1" ] && [ -n "${!env_var:-}" ]; then
366
+ return 0
367
+ fi
368
+
369
+ # Skip empty / explicit null (parser sentinels, not an error).
370
+ if [ -z "$value" ] || [ "$value" = "null" ]; then
371
+ return 0
372
+ fi
373
+
374
+ # Expand ${VAR} refs BEFORE validation.
375
+ local expanded
376
+ if ! expanded="$(loki_expand_refs "$value")"; then
377
+ # expanded holds the unresolved var name here.
378
+ printf 'loki: config: skipping %s -- unresolved ${%s}\n' "$env_var" "$expanded" >&2
379
+ return 1
380
+ fi
381
+
382
+ if ! loki_validate_value "$expanded"; then
383
+ printf 'loki: config: rejected %s -- value failed validation (shell metachar / length / newline)\n' "$env_var" >&2
384
+ return 1
385
+ fi
386
+
387
+ export "$env_var=$expanded"
388
+ return 0
389
+ }
390
+
391
+ #===============================================================================
392
+ # 5b. Raw-secret literal detector (reuse the shipped scanner patterns)
393
+ #===============================================================================
394
+ # Returns 0 if VALUE (a single literal, NOT a ${VAR} ref) looks like a secret.
395
+ # A ${VAR}-ref value is correctly IGNORED (handled by the deny filter, same as
396
+ # run.sh _commit_scan_secret_file). Used by config-file load (warn) and
397
+ # `config validate` (error).
398
+ loki_value_looks_secret() {
399
+ local value="${1:-}"
400
+ [ -n "$value" ] || return 1
401
+
402
+ # TIER 1: specific formats. No deny filter -- a format match is a finding.
403
+ local tier1=(
404
+ 'AKIA[0-9A-Z]{16}'
405
+ 'ASIA[0-9A-Z]{16}'
406
+ '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----'
407
+ 'gh[pousr]_[A-Za-z0-9]{36,}'
408
+ 'github_pat_[A-Za-z0-9_]{60,}'
409
+ 'xox[baprs]-[A-Za-z0-9-]{10,}'
410
+ 'sk-[A-Za-z0-9]{20,}'
411
+ 'AIza[0-9A-Za-z_-]{35}'
412
+ 'glpat-[A-Za-z0-9_-]{20,}'
413
+ )
414
+ local p
415
+ for p in "${tier1[@]}"; do
416
+ if printf '%s' "$value" | LC_ALL=C grep -Eq -e "$p" 2>/dev/null; then
417
+ return 0
418
+ fi
419
+ done
420
+
421
+ # Deny filter (same as run.sh): ${VAR} refs and placeholders are NOT secrets.
422
+ local deny='(\$\{|\$[A-Za-z_]|process\.env|os\.(environ|getenv)|%[A-Za-z_]+%|your[-_]|redacted|changeme|change[-_]me|placeholder|example|dummy|sample|fake|<[^>]*>|x{4,}|\*{4,})'
423
+ local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
424
+ local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
425
+ local uricred='[a-z][a-z0-9+.\-]*://[^/[:space:]:@]*:[^/[:space:]:@]+@'
426
+
427
+ local surviving
428
+ surviving="$( { printf '%s' "$value" | LC_ALL=C grep -EiI "$tier2|$bearer|$uricred" 2>/dev/null \
429
+ | LC_ALL=C grep -Eiv "$deny" 2>/dev/null; } || true)"
430
+ if [ -n "$surviving" ]; then return 0; fi
431
+ return 1
432
+ }
433
+
434
+ #===============================================================================
435
+ # 4. Format detection
436
+ #===============================================================================
437
+ # Echoes "env" | "yaml" | "json" | "" (unknown). Uses extension first, then a
438
+ # content sniff on the first non-blank, non-comment line.
439
+ loki_detect_config_format() {
440
+ local path="$1"
441
+ local base="${path##*/}"
442
+ case "$base" in
443
+ *.env|.env.*) printf 'env'; return 0 ;;
444
+ *.yaml|*.yml) printf 'yaml'; return 0 ;;
445
+ *.json) printf 'json'; return 0 ;;
446
+ esac
447
+ # Content sniff: first meaningful line.
448
+ local line
449
+ line="$( { grep -vE '^[[:space:]]*($|#)' "$path" 2>/dev/null | head -1; } || true)"
450
+ line="${line#"${line%%[![:space:]]*}"}" # ltrim
451
+ if [[ "$line" == \{* ]]; then
452
+ printf 'json'; return 0
453
+ elif [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]]; then
454
+ printf 'env'; return 0
455
+ elif [[ "$line" =~ ^[a-zA-Z0-9_.-]+: ]]; then
456
+ printf 'yaml'; return 0
457
+ fi
458
+ printf ''
459
+ return 1
460
+ }
461
+
462
+ #===============================================================================
463
+ # 4a. Flat .env parser (FULL ~250-flag coverage, day one)
464
+ #===============================================================================
465
+ # Reads KEY=VALUE lines; key allowlist ^LOKI_[A-Z0-9_]+$; each value goes through
466
+ # loki_config_export_key with the given override. Non-allowlisted keys are
467
+ # rejected with a visible warning (never a silent skip).
468
+ loki_parse_env_file() {
469
+ local file="$1"
470
+ local override="${2:-1}"
471
+ local line key val
472
+ while IFS= read -r line || [ -n "$line" ]; do
473
+ # Skip blank and comment lines.
474
+ case "$line" in
475
+ ''|'#'*) continue ;;
476
+ esac
477
+ # Strip a leading "export " prefix (common in .env files).
478
+ line="${line#export }"
479
+ # Must contain '='.
480
+ case "$line" in
481
+ *=*) ;;
482
+ *) continue ;;
483
+ esac
484
+ key="${line%%=*}"
485
+ val="${line#*=}"
486
+ # Trim whitespace around key.
487
+ key="${key#"${key%%[![:space:]]*}"}"
488
+ key="${key%"${key##*[![:space:]]}"}"
489
+ # Trim leading whitespace on value (a quoted value preserves inner spaces).
490
+ val="${val#"${val%%[![:space:]]*}"}"
491
+ # Strip one layer of matching surrounding quotes.
492
+ if [[ "$val" == \"*\" && ${#val} -ge 2 ]]; then
493
+ val="${val:1:${#val}-2}"
494
+ elif [[ "$val" == \'*\' && ${#val} -ge 2 ]]; then
495
+ val="${val:1:${#val}-2}"
496
+ fi
497
+ # Key allowlist: must look like a LOKI_ key AND be a recognized var
498
+ # (config-map member or an allowlisted non-map var). An unknown LOKI_ key
499
+ # is a typo or an unadvertised key; warn and skip it rather than export.
500
+ if [[ ! "$key" =~ ^LOKI_[A-Z0-9_]+$ ]]; then
501
+ printf 'loki: config: ignoring non-allowlisted key %s (only LOKI_* keys are accepted)\n' "$key" >&2
502
+ continue
503
+ fi
504
+ if ! loki_env_key_is_known "$key"; then
505
+ printf 'loki: config: WARNING ignoring unknown key %s (not a recognized LOKI_ config var -- typo?)\n' "$key" >&2
506
+ continue
507
+ fi
508
+ # Raw-secret warning on a literal (non-ref) value.
509
+ if [[ "$val" != *'${'* ]] && loki_value_looks_secret "$val"; then
510
+ printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$key" >&2
511
+ fi
512
+ loki_config_export_key "$key" "$val" "$override" || true
513
+ done < "$file"
514
+ }
515
+
516
+ #===============================================================================
517
+ # 4. YAML parsing (yq if present, grep/sed fallback) over LOKI_CONFIG_MAP
518
+ #===============================================================================
519
+ loki_parse_yaml_file() {
520
+ local file="$1"
521
+ local override="${2:-1}"
522
+ local mapping yaml_path env_var value
523
+ local have_yq=0
524
+ command -v yq >/dev/null 2>&1 && have_yq=1
525
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
526
+ yaml_path="${mapping%%:*}"
527
+ env_var="${mapping##*:}"
528
+ if [ "$have_yq" = "1" ]; then
529
+ value="$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
530
+ else
531
+ # grep/sed fallback: match the LAST path segment as a key.
532
+ # `|| true` keeps the no-match case (grep rc=1 under pipefail) from
533
+ # tripping set -e in the loki CLI (which runs set -euo pipefail).
534
+ local key escaped_key
535
+ key="${yaml_path##*.}"
536
+ escaped_key="$(loki_escape_regex "$key")"
537
+ value="$( { grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 \
538
+ | sed -E 's/.*:\s*//' | sed 's/#.*//' \
539
+ | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\n' \
540
+ | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } || true)"
541
+ fi
542
+ if [ "$value" = "null" ]; then value=""; fi
543
+ if [ -z "$value" ]; then continue; fi
544
+ # Raw-secret warning on a literal value.
545
+ if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
546
+ printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$env_var" >&2
547
+ fi
548
+ loki_config_export_key "$env_var" "$value" "$override" || true
549
+ done
550
+ }
551
+
552
+ #===============================================================================
553
+ # 4. JSON parsing (yq native, else audited python3 pattern from _load_json_settings)
554
+ #===============================================================================
555
+ # Emits "env_var<TAB>value" lines for nested.path keys present in the file.
556
+ # Modeled on run.sh:_load_json_settings (json.load, isinstance(str) guard). The
557
+ # shell side then routes each value through loki_config_export_key, so ${VAR}
558
+ # expansion / validation / secret-warning happen uniformly with the other formats.
559
+ loki_parse_json_file() {
560
+ local file="$1"
561
+ local override="${2:-1}"
562
+
563
+ if command -v yq >/dev/null 2>&1; then
564
+ local mapping yaml_path env_var value
565
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
566
+ yaml_path="${mapping%%:*}"
567
+ env_var="${mapping##*:}"
568
+ value="$(yq eval -p=json ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
569
+ if [ "$value" = "null" ]; then value=""; fi
570
+ if [ -z "$value" ]; then continue; fi
571
+ if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
572
+ printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$env_var" >&2
573
+ fi
574
+ loki_config_export_key "$env_var" "$value" "$override" || true
575
+ done
576
+ return 0
577
+ fi
578
+
579
+ # python3 fallback. The python side ONLY reads + emits tab-separated
580
+ # env_var/value pairs (NO export, NO eval). Values are scalars coerced to
581
+ # str; the shell does expansion/validation/export. The mapping is passed in
582
+ # via env so it cannot drift from LOKI_CONFIG_MAP.
583
+ if ! command -v python3 >/dev/null 2>&1; then
584
+ printf 'loki: config: cannot parse JSON -- neither yq nor python3 found\n' >&2
585
+ return 1
586
+ fi
587
+ local map_str=""
588
+ local mapping
589
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
590
+ map_str+="$mapping"$'\n'
591
+ done
592
+ local emitted
593
+ emitted="$(_LOKI_CFG_JSON="$file" _LOKI_CFG_MAP="$map_str" python3 -c '
594
+ import json, os, sys
595
+
596
+ def get_nested(d, key):
597
+ cur = d
598
+ for p in key.split("."):
599
+ if isinstance(cur, dict):
600
+ cur = cur.get(p)
601
+ else:
602
+ return None
603
+ return cur
604
+
605
+ try:
606
+ with open(os.environ["_LOKI_CFG_JSON"]) as f:
607
+ data = json.load(f)
608
+ except Exception:
609
+ sys.exit(0)
610
+
611
+ for line in os.environ.get("_LOKI_CFG_MAP", "").splitlines():
612
+ line = line.strip()
613
+ if not line or ":" not in line:
614
+ continue
615
+ path, env_var = line.split(":", 1)
616
+ val = get_nested(data, path)
617
+ if val is None:
618
+ continue
619
+ if isinstance(val, bool):
620
+ val = "true" if val else "false"
621
+ elif isinstance(val, (int, float)):
622
+ val = repr(val) if isinstance(val, float) else str(val)
623
+ elif not isinstance(val, str):
624
+ continue
625
+ # Tab + newline would corrupt the record; skip such values (validation
626
+ # would reject newlines anyway).
627
+ if "\t" in val or "\n" in val:
628
+ continue
629
+ sys.stdout.write(env_var + "\t" + val + "\n")
630
+ ' 2>/dev/null)" || true
631
+
632
+ local env_var value
633
+ while IFS=$'\t' read -r env_var value; do
634
+ [ -n "$env_var" ] || continue
635
+ if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
636
+ printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$env_var" >&2
637
+ fi
638
+ loki_config_export_key "$env_var" "$value" "$override" || true
639
+ done <<< "$emitted"
640
+ }
641
+
642
+ #===============================================================================
643
+ # 4 (top). loki_apply_config_file <path> [override]
644
+ #===============================================================================
645
+ # Validates the path, detects format, routes to the right parser. Default
646
+ # override=1 (the pre-pass case: config beats ambient env). Missing/unreadable
647
+ # path -> honest non-zero, NO silent fallback.
648
+ loki_apply_config_file() {
649
+ local path="$1"
650
+ local override="${2:-1}"
651
+
652
+ if [ -z "$path" ]; then
653
+ printf 'loki: config: no config path given\n' >&2
654
+ return 1
655
+ fi
656
+ if [ ! -e "$path" ]; then
657
+ printf 'loki: config: file not found: %s\n' "$path" >&2
658
+ return 1
659
+ fi
660
+ # Symlink guard for project-local relative paths (mirror load_config_file).
661
+ # Absolute paths (operator-mounted, e.g. /etc/loki/config.yaml) and paths in
662
+ # the user's HOME are trusted; a relative project path that is a symlink is
663
+ # rejected to prevent path-traversal via a planted link in the repo.
664
+ case "$path" in
665
+ /*|"$HOME"/*) : ;;
666
+ *)
667
+ if [ -L "$path" ]; then
668
+ printf 'loki: config: refusing symlinked project-local config: %s\n' "$path" >&2
669
+ return 1
670
+ fi
671
+ ;;
672
+ esac
673
+ if [ ! -r "$path" ]; then
674
+ printf 'loki: config: file not readable: %s\n' "$path" >&2
675
+ return 1
676
+ fi
677
+
678
+ local fmt
679
+ fmt="$(loki_detect_config_format "$path")"
680
+ case "$fmt" in
681
+ env) loki_parse_env_file "$path" "$override" ;;
682
+ yaml) loki_parse_yaml_file "$path" "$override" ;;
683
+ json) loki_parse_json_file "$path" "$override" ;;
684
+ *)
685
+ printf 'loki: config: cannot detect format for %s (expected .env/.yaml/.yml/.json or recognizable content)\n' "$path" >&2
686
+ return 1
687
+ ;;
688
+ esac
689
+ }
690
+
691
+ #===============================================================================
692
+ # 1. loki_maybe_apply_config_file "$@" (the loki pre-pass entry point)
693
+ #===============================================================================
694
+ # Honors LOKI_CONFIG_FILE env first, then scans "$@" for --config/--vars/
695
+ # --env-file (space and = forms) WITHOUT consuming. An explicit flag overrides
696
+ # the env var. If a path is found, applies it with override=1 (config beats env).
697
+ loki_maybe_apply_config_file() {
698
+ local path=""
699
+ # 1. env var first.
700
+ if [ -n "${LOKI_CONFIG_FILE:-}" ]; then
701
+ path="$LOKI_CONFIG_FILE"
702
+ fi
703
+ # 2. scan args (explicit flag overrides the env var).
704
+ local prev=""
705
+ local a
706
+ for a in "$@"; do
707
+ case "$prev" in
708
+ --config|--vars|--env-file)
709
+ path="$a"
710
+ ;;
711
+ esac
712
+ case "$a" in
713
+ --config=*|--vars=*|--env-file=*)
714
+ path="${a#*=}"
715
+ ;;
716
+ esac
717
+ prev="$a"
718
+ done
719
+ [ -n "$path" ] || return 0
720
+ loki_apply_config_file "$path" 1
721
+ }
722
+
723
+ #===============================================================================
724
+ # 6. Generators: config example / schema (from LOKI_CONFIG_MAP, never drift)
725
+ #===============================================================================
726
+
727
+ # Look up the comment for a path (LOKI_CONFIG_COMMENTS). Echoes "" if absent.
728
+ _loki_config_comment_for() {
729
+ local want="$1"
730
+ local entry
731
+ for entry in "${LOKI_CONFIG_COMMENTS[@]}"; do
732
+ if [ "${entry%%:*}" = "$want" ]; then
733
+ printf '%s' "${entry#*:}"
734
+ return 0
735
+ fi
736
+ done
737
+ printf ''
738
+ }
739
+
740
+ # Emit an annotated nested YAML skeleton generated from LOKI_CONFIG_MAP.
741
+ loki_config_generate_example() {
742
+ printf '# Loki Mode Configuration File (generated by: loki config example)\n'
743
+ printf '# Copy to .loki/config.yaml, or pass with: loki start --config <path>\n'
744
+ printf '# Precedence: CLI flags > --config file > ambient env > auto .loki/config.yaml > defaults\n'
745
+ printf '# Secrets: never inline. Reference an env var with ${VAR_NAME}.\n'
746
+ printf '\n'
747
+ local mapping path top sub leaf comment
748
+ local cur_top="" cur_sub=""
749
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
750
+ path="${mapping%%:*}"
751
+ # Split into up to 3 segments: top.[sub.]leaf
752
+ local IFS_save="$IFS"
753
+ IFS='.' read -r p1 p2 p3 <<< "$path"
754
+ IFS="$IFS_save"
755
+ if [ -n "$p3" ]; then
756
+ top="$p1"; sub="$p2"; leaf="$p3"
757
+ else
758
+ top="$p1"; sub=""; leaf="$p2"
759
+ fi
760
+ comment="$(_loki_config_comment_for "$path")"
761
+ if [ "$top" != "$cur_top" ]; then
762
+ cur_top="$top"; cur_sub=""
763
+ printf '%s:\n' "$top"
764
+ fi
765
+ if [ -n "$sub" ]; then
766
+ if [ "$sub" != "$cur_sub" ]; then
767
+ cur_sub="$sub"
768
+ printf ' %s:\n' "$sub"
769
+ fi
770
+ [ -n "$comment" ] && printf ' # %s\n' "$comment"
771
+ printf ' %s:\n' "$leaf"
772
+ else
773
+ cur_sub=""
774
+ [ -n "$comment" ] && printf ' # %s\n' "$comment"
775
+ printf ' %s:\n' "$leaf"
776
+ fi
777
+ done
778
+ }
779
+
780
+ # Emit a machine-readable key -> LOKI_ENV_VAR table.
781
+ loki_config_generate_schema() {
782
+ printf '# key\tenv_var\n'
783
+ local mapping
784
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
785
+ printf '%s\t%s\n' "${mapping%%:*}" "${mapping##*:}"
786
+ done
787
+ }
788
+
789
+ # Validate a config file for `loki config validate <file>`. Reports unresolved
790
+ # refs, raw-secret literals (ERROR), and per-value validation failures. Returns
791
+ # non-zero on ANY failure. Reads the file directly (format-aware) WITHOUT
792
+ # exporting anything into the environment.
793
+ loki_config_validate_file() {
794
+ local path="$1"
795
+ local rc=0
796
+
797
+ if [ -z "$path" ] || [ ! -e "$path" ]; then
798
+ printf 'loki: config validate: file not found: %s\n' "$path" >&2
799
+ return 1
800
+ fi
801
+ if [ ! -r "$path" ]; then
802
+ printf 'loki: config validate: file not readable: %s\n' "$path" >&2
803
+ return 1
804
+ fi
805
+ local fmt
806
+ fmt="$(loki_detect_config_format "$path")"
807
+ if [ -z "$fmt" ]; then
808
+ printf 'loki: config validate: cannot detect format for %s\n' "$path" >&2
809
+ return 1
810
+ fi
811
+
812
+ # Collect "env_var<TAB>value" pairs WITHOUT exporting. We reuse the parsers'
813
+ # extraction by capturing each candidate through a subshell that only prints.
814
+ local pairs
815
+ pairs="$(
816
+ _loki_cfg_collect_pairs() {
817
+ local f="$1" fm="$2"
818
+ case "$fm" in
819
+ env)
820
+ local line key val
821
+ while IFS= read -r line || [ -n "$line" ]; do
822
+ case "$line" in ''|'#'*) continue ;; esac
823
+ line="${line#export }"
824
+ case "$line" in *=*) ;; *) continue ;; esac
825
+ key="${line%%=*}"; val="${line#*=}"
826
+ key="${key#"${key%%[![:space:]]*}"}"; key="${key%"${key##*[![:space:]]}"}"
827
+ val="${val#"${val%%[![:space:]]*}"}"
828
+ if [[ "$val" == \"*\" && ${#val} -ge 2 ]]; then val="${val:1:${#val}-2}";
829
+ elif [[ "$val" == \'*\' && ${#val} -ge 2 ]]; then val="${val:1:${#val}-2}"; fi
830
+ printf '%s\t%s\n' "$key" "$val"
831
+ done < "$f"
832
+ ;;
833
+ yaml)
834
+ local mapping yaml_path env_var value key escaped_key have_yq=0
835
+ if command -v yq >/dev/null 2>&1; then have_yq=1; fi
836
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
837
+ yaml_path="${mapping%%:*}"; env_var="${mapping##*:}"
838
+ if [ "$have_yq" = 1 ]; then
839
+ value="$(yq eval ".$yaml_path // \"\"" "$f" 2>/dev/null || true)"
840
+ else
841
+ key="${yaml_path##*.}"; escaped_key="$(loki_escape_regex "$key")"
842
+ value="$( { grep -E "^\s*${escaped_key}:" "$f" 2>/dev/null | head -1 \
843
+ | sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["'\'']//;s/["'\'']$//' \
844
+ | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } || true)"
845
+ fi
846
+ if [ "$value" = "null" ]; then value=""; fi
847
+ if [ -z "$value" ]; then continue; fi
848
+ printf '%s\t%s\n' "$env_var" "$value"
849
+ done
850
+ ;;
851
+ json)
852
+ # Reuse the json parser's emit path by calling a print-only variant.
853
+ _loki_cfg_json_emit "$f"
854
+ ;;
855
+ esac
856
+ }
857
+ _loki_cfg_collect_pairs "$path" "$fmt"
858
+ )"
859
+
860
+ local env_var value expanded
861
+ while IFS=$'\t' read -r env_var value; do
862
+ [ -n "$env_var" ] || continue
863
+ # env key allowlist for .env format: must look like a LOKI_ key AND be a
864
+ # recognized var. YAML/JSON are bound to the config map by extraction, so
865
+ # this membership check brings .env to parity and catches typos /
866
+ # unadvertised keys that `config validate` should reject.
867
+ if [ "$fmt" = "env" ]; then
868
+ if [[ ! "$env_var" =~ ^LOKI_[A-Z0-9_]+$ ]]; then
869
+ printf 'loki: config validate: ERROR non-allowlisted key %s\n' "$env_var" >&2
870
+ rc=1
871
+ continue
872
+ fi
873
+ if ! loki_env_key_is_known "$env_var"; then
874
+ printf 'loki: config validate: ERROR unknown key %s (not a recognized LOKI_ config var -- typo?)\n' "$env_var" >&2
875
+ rc=1
876
+ continue
877
+ fi
878
+ fi
879
+ # Raw-secret literal is an ERROR in validate.
880
+ if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
881
+ printf 'loki: config validate: ERROR %s contains a raw secret literal -- use ${VAR}\n' "$env_var" >&2
882
+ rc=1
883
+ continue
884
+ fi
885
+ # Dry-expand refs; report unresolved.
886
+ if ! expanded="$(loki_expand_refs "$value")"; then
887
+ printf 'loki: config validate: ERROR %s references unresolved ${%s}\n' "$env_var" "$expanded" >&2
888
+ rc=1
889
+ continue
890
+ fi
891
+ # Per-value validation.
892
+ if ! loki_validate_value "$expanded"; then
893
+ printf 'loki: config validate: ERROR %s failed value validation (shell metachar / length / newline)\n' "$env_var" >&2
894
+ rc=1
895
+ continue
896
+ fi
897
+ done <<< "$pairs"
898
+
899
+ if [ "$rc" = 0 ]; then
900
+ printf 'loki: config validate: OK -- %s\n' "$path"
901
+ fi
902
+ return "$rc"
903
+ }
904
+
905
+ # Print-only JSON emit (env_var<TAB>value) without export. Used by validate.
906
+ _loki_cfg_json_emit() {
907
+ local file="$1"
908
+ if command -v yq >/dev/null 2>&1; then
909
+ local mapping yaml_path env_var value
910
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
911
+ yaml_path="${mapping%%:*}"; env_var="${mapping##*:}"
912
+ value="$(yq eval -p=json ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
913
+ if [ "$value" = "null" ]; then value=""; fi
914
+ if [ -z "$value" ]; then continue; fi
915
+ printf '%s\t%s\n' "$env_var" "$value"
916
+ done
917
+ return 0
918
+ fi
919
+ command -v python3 >/dev/null 2>&1 || return 0
920
+ local map_str="" mapping
921
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do map_str+="$mapping"$'\n'; done
922
+ _LOKI_CFG_JSON="$file" _LOKI_CFG_MAP="$map_str" python3 -c '
923
+ import json, os, sys
924
+ def get_nested(d, key):
925
+ cur = d
926
+ for p in key.split("."):
927
+ if isinstance(cur, dict):
928
+ cur = cur.get(p)
929
+ else:
930
+ return None
931
+ return cur
932
+ try:
933
+ with open(os.environ["_LOKI_CFG_JSON"]) as f:
934
+ data = json.load(f)
935
+ except Exception:
936
+ sys.exit(0)
937
+ for line in os.environ.get("_LOKI_CFG_MAP", "").splitlines():
938
+ line = line.strip()
939
+ if not line or ":" not in line:
940
+ continue
941
+ path, env_var = line.split(":", 1)
942
+ val = get_nested(data, path)
943
+ if val is None:
944
+ continue
945
+ if isinstance(val, bool):
946
+ val = "true" if val else "false"
947
+ elif isinstance(val, (int, float)):
948
+ val = repr(val) if isinstance(val, float) else str(val)
949
+ elif not isinstance(val, str):
950
+ continue
951
+ if "\t" in val or "\n" in val:
952
+ continue
953
+ sys.stdout.write(env_var + "\t" + val + "\n")
954
+ ' 2>/dev/null || true
955
+ }