114 lines
8.6 KiB
Plaintext
114 lines
8.6 KiB
Plaintext
|
|
let d = import "defaults.ncl" in
|
||
|
|
|
||
|
|
d.make_adr {
|
||
|
|
id = "adr-004",
|
||
|
|
title = "NCL Pipe Bootstrap — Config Validation and Secret Injection via Unix Pipeline",
|
||
|
|
status = 'Accepted,
|
||
|
|
date = "2026-03-13",
|
||
|
|
|
||
|
|
context = "Ontoref-daemon and any process that receives structured config faces two problems: (1) Nickel NCL requires a subprocess to evaluate, introducing a system-call injection surface if the daemon itself calls `nickel export` at runtime; (2) credentials and secrets embedded in config files (TOML, JSON) persist on disk after the process starts, creating a forensic artifact. The existing registry.toml approach (NCL → TOML file → daemon reads file) partially addresses the first problem but not the second — the TOML file remains on disk with hashed credentials. SOPS and Vault are standard secret management tools that produce decrypted output on stdout.",
|
||
|
|
|
||
|
|
decision = "All config delivery to long-running processes follows a three-stage Unix pipeline: Stage 1 — structural validation: `nickel export --format json config.ncl` produces JSON with schema-validated structure but no secret values; Stage 2 — secret injection (optional): SOPS decrypt or Vault lookup merges credentials into the JSON stream; Stage 3 — process bootstrap: the target process reads the composed JSON from stdin via `--config-stdin`. No intermediate file is written to disk. If any stage fails, the pipeline breaks and the process does not start. A bash wrapper script (not Nu — Nu may not be available at service boot time) orchestrates the pipeline. A Nu helper `ncl-bootstrap` provides the same interface for interactive/development use.",
|
||
|
|
|
||
|
|
rationale = [
|
||
|
|
{
|
||
|
|
claim = "Pipe eliminates disk artifacts for secrets",
|
||
|
|
detail = "Pipe contents are kernel-managed memory, inaccessible to other processes, and ephemeral — no filesystem metadata, no tmpfs entry, nothing survives process termination. A TOML or JSON file on disk persists until explicitly deleted, is accessible to any process running as the same UID, and may appear in filesystem audit logs. For secrets (DB passwords, API keys, Argon2id hashes), the pipe is materially safer.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Nickel is the validation layer, not the runtime config format",
|
||
|
|
detail = "The daemon never calls `nickel export` — it only reads JSON from stdin. The NCL schema enforces structural correctness and type safety before the process starts. This separates concerns: NCL for authoring and validation, JSON for delivery. No Nickel subprocess risk at runtime.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Bash wrapper for service launchers, Nu helper for development",
|
||
|
|
detail = "System service managers (launchctl, systemd) run in environments where Nu may not be on PATH or may be a different version. The bash wrapper has zero dependencies beyond bash, nickel, and the target binary. The Nu ncl-bootstrap helper provides richer error messages and structured output for interactive development use. Both implement the same pipeline.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "SOPS and Vault integrate as a composable pipeline stage",
|
||
|
|
detail = "Stage 2 is optional and replaceable. With SOPS: `sops --decrypt secrets.enc.json`. With Vault: `vault kv get -format=json secret/path | jq '.data.data'`. Both produce JSON on stdout. `jq -s '.[0] * .[1]'` merges the structural config with the secrets. The NCL file never contains secret values — only SecretRef placeholders if self-documentation is desired.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Pipeline failure semantics are correct by default",
|
||
|
|
detail = "If `nickel export` fails (schema violation, syntax error), stdout is empty or truncated — the target process receives invalid JSON and fails at parse time, before any initialization. If SOPS or Vault fails, same result. The process never starts in a partially-configured state. This is safer than file-based config where the process may start with a stale or invalid file.",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
consequences = {
|
||
|
|
positive = [
|
||
|
|
"No credentials on disk after process startup — ephemeral pipe only",
|
||
|
|
"NCL schema violations prevent daemon startup — config errors are caught early",
|
||
|
|
"SOPS and Vault integrate without changes to the daemon — secrets are a pipeline concern",
|
||
|
|
"Pattern is reusable: any project can adopt ncl-bootstrap for any long-running process",
|
||
|
|
"Bash wrapper works in Docker, CI, launchctl, systemd without Nu dependency",
|
||
|
|
],
|
||
|
|
negative = [
|
||
|
|
"Daemon loses ability to hot-reload config from disk — config changes require restart via wrapper",
|
||
|
|
"stdin is consumed at startup — daemon must redirect stdin to /dev/null after reading config",
|
||
|
|
"Pipeline debugging is harder than inspecting a config file — need wrapper --dry-run mode",
|
||
|
|
"Nu must not be required for the bash wrapper — two implementations of the same pattern to maintain",
|
||
|
|
],
|
||
|
|
},
|
||
|
|
|
||
|
|
alternatives_considered = [
|
||
|
|
{
|
||
|
|
option = "TOML file on disk (current registry.toml approach)",
|
||
|
|
why_rejected = "File persists on disk with credentials. Stale file may be read after config changes. Requires explicit cleanup logic. Forensic artifact risk.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
option = "Environment variables for secrets",
|
||
|
|
why_rejected = "Environment variables are visible in /proc/PID/environ on Linux and via `ps eww` on some systems. They persist for the lifetime of the process and are inherited by child processes. Worse attack surface than stdin pipe.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
option = "Encrypted TOML file (AES256 at rest)",
|
||
|
|
why_rejected = "Decryption key must be available at runtime — the problem is deferred, not solved. The decrypted form still passes through disk (tmpfs or swap). Adds a custom encryption layer instead of using standard tools (SOPS, Vault) that the ecosystem already supports.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
option = "Daemon reads NCL directly at runtime via nickel-lang-core",
|
||
|
|
why_rejected = "nickel-lang-core has an unstable Rust API. More critically, it means the daemon can evaluate arbitrary Nickel — including NCL files with system calls via builtins. The pipeline approach ensures the daemon only ever sees validated JSON, never executable Nickel.",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
constraints = [
|
||
|
|
{
|
||
|
|
id = "no-config-file-on-disk",
|
||
|
|
claim = "The bootstrap pipeline must not write an intermediate config file to disk at any stage",
|
||
|
|
scope = "scripts/ontoref-daemon-start, reflection/nulib/bootstrap.nu",
|
||
|
|
severity = 'Hard,
|
||
|
|
check_hint = "grep -E 'tee|>|tempfile|mktemp' scripts/ontoref-daemon-start",
|
||
|
|
rationale = "An intermediate file defeats the purpose of the pipeline. If a file is needed for debugging, use --dry-run which prints to stdout only.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "bash-wrapper-zero-deps",
|
||
|
|
claim = "The bash wrapper must depend only on bash, nickel, and the target binary — no Nu, no jq unless SOPS/Vault stage is active",
|
||
|
|
scope = "scripts/ontoref-daemon-start",
|
||
|
|
severity = 'Hard,
|
||
|
|
check_hint = "head -5 scripts/ontoref-daemon-start",
|
||
|
|
rationale = "System service managers may not have Nu on PATH. The wrapper must be portable across launchctl, systemd, Docker entrypoints.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "config-stdin-closes-after-read",
|
||
|
|
claim = "The target process must redirect stdin to /dev/null after reading the config JSON",
|
||
|
|
scope = "crates/ontoref-daemon/src/main.rs",
|
||
|
|
severity = 'Hard,
|
||
|
|
check_hint = "rg 'config.stdin\\|/dev/null\\|stdin.*close' crates/ontoref-daemon/src/main.rs",
|
||
|
|
rationale = "stdin left open blocks terminal interaction and causes confusion in interactive sessions. The daemon is a server — it must not hold stdin.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "ncl-no-secret-values",
|
||
|
|
claim = "NCL config files used with ncl-bootstrap must not contain plaintext secret values — only SecretRef placeholders or empty fields",
|
||
|
|
scope = ".ontoref/config.ncl, APP_SUPPORT/ontoref/config.ncl",
|
||
|
|
severity = 'Hard,
|
||
|
|
check_hint = "nickel export .ontoref/config.ncl | jq 'paths(scalars) | select(test(\"password|secret|key|token|hash\"))'",
|
||
|
|
rationale = "If secrets are in the NCL file, they are readable as plaintext by anyone with filesystem access. Secrets enter the pipeline only at the SOPS/Vault stage.",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
related_adrs = ["adr-002-daemon-for-caching-and-notification-barrier"],
|
||
|
|
|
||
|
|
ontology_check = {
|
||
|
|
decision_string = "NCL pipe bootstrap — config validation via nickel export piped to process stdin, secrets injected via SOPS/Vault as optional pipeline stage, no intermediate files",
|
||
|
|
invariants_at_risk = ["protocol-not-runtime"],
|
||
|
|
verdict = 'Safe,
|
||
|
|
},
|
||
|
|
}
|