← Files NightshiftARCHIVED FILE

lib/rules-read.sh

15.5 KB · Oct 4, 2026 · 12:30 UTC

↓ Download file

#!/usr/bin/env bash
# Strict-subset reader for Nightshift's rules.json.
#
# One owner file, one shape: the shipped template's objects, strings, bools,
# integers, and string arrays. Comments, trailing commas, unknown types, and
# unexpected nesting fail closed. Never a second store. Never a general parser.

NS_RULES_ERR=""
NS_RULES_ROWS=""
NS_RULES_FILE=""
NS_RULES_STAMP=""
NS_RULES_MAP=""
NS_RULES_MAP_SRC=""

_NS_RULES_NL='
'
_NS_RULES_TAB=$(printf '\t')

# The program sits next to this file, resolved without dirname so a hostile PATH
# cannot reach a different reader.
_NS_RULES_AWK_FILE="${BASH_SOURCE[0]%/*}"
[ "$_NS_RULES_AWK_FILE" != "${BASH_SOURCE[0]}" ] || _NS_RULES_AWK_FILE=.
_NS_RULES_AWK_FILE="$_NS_RULES_AWK_FILE/rules-read.awk"

# ns_rules_awk_bin — awk on PATH, or NS_RULES_AWK when a test pins one.
ns_rules_awk_bin() {
  if [ -n "${NS_RULES_AWK:-}" ]; then
    printf '%s' "$NS_RULES_AWK"
    return 0
  fi
  command -v awk
}

# _ns_rules_scan <mode> [file|-] — path/type/value rows on stdout. Status 1
# prints ERR<TAB>reason on stdout.
_ns_rules_scan() {
  local bin
  bin="$(ns_rules_awk_bin)" || {
    NS_RULES_ERR="rules.json cannot be read"
    return 1
  }
  "$bin" -v mode="$1" -f "$_NS_RULES_AWK_FILE" "${2:--}"
}

# _ns_rules_ingest <mode> [file|-] — load rows or set NS_RULES_ERR.
_ns_rules_ingest() {
  local out rc
  out="$(_ns_rules_scan "$1" "${2:--}")"
  rc=$?
  case "$out" in
    ERR"$_NS_RULES_TAB"*)
      NS_RULES_ERR="${out#ERR"$_NS_RULES_TAB"}"
      NS_RULES_ERR="${NS_RULES_ERR%%"$_NS_RULES_NL"*}"
      return 1
      ;;
  esac
  [ "$rc" -eq 0 ] || {
    NS_RULES_ERR="rules.json cannot be read"
    return 1
  }
  NS_RULES_ROWS="$out"
  return 0
}

# ns_rules_load <file> — parse rules.json into NS_RULES_ROWS. Status 1 names
# the reason in NS_RULES_ERR.
ns_rules_load() {
  local f="$1" stamp
  NS_RULES_ERR=""
  if [ ! -f "$f" ]; then
    NS_RULES_ERR="missing"
    NS_RULES_FILE=""
    NS_RULES_STAMP=""
    NS_RULES_ROWS=""
    return 1
  fi
  stamp=""
  if command -v wc >/dev/null 2>&1 && command -v cksum >/dev/null 2>&1; then
    stamp="$(wc -c <"$f" | tr -d ' '):$(cksum <"$f")"
  fi
  if [ -n "$stamp" ] && [ "$NS_RULES_FILE" = "$f" ] && [ "$NS_RULES_STAMP" = "$stamp" ] && [ -n "$NS_RULES_ROWS" ]; then
    return 0
  fi
  NS_RULES_FILE=""
  NS_RULES_STAMP=""
  NS_RULES_ROWS=""
  _ns_rules_ingest rules "$f" || {
    NS_RULES_FILE=""
    NS_RULES_STAMP=""
    NS_RULES_ROWS=""
    return 1
  }
  NS_RULES_FILE="$f"
  NS_RULES_STAMP="$stamp"
  return 0
}

# ns_rules_check <workspace> — status 0 the file is the accepted shape.
# Status 1 prints one named reason. Status 3 the file is absent.
ns_rules_check() {
  local f
  ns_layout_set f "$1/.nightshift" rules
  if [ ! -f "$f" ]; then
    printf 'missing\n'
    return 3
  fi
  ns_rules_load "$f" && return 0
  printf '%s\n' "$NS_RULES_ERR"
  return 1
}

# _ns_rules_row <p1> <p2> <p3> — type<TAB>json-value of that path, or status 1.
_ns_rules_row() {
  local rest="$NS_RULES_ROWS" line p1 p2 p3
  while [ -n "$rest" ]; do
    line="${rest%%"$_NS_RULES_NL"*}"
    case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    p1="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p2="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p3="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    if [ "$p1" != "$1" ] || [ "$p2" != "$2" ] || [ "$p3" != "$3" ]; then
      continue
    fi
    printf '%s' "$line"
    return 0
  done
  return 1
}

# ns_json_text <compact-json> — a JSON string unquoted; any other scalar as-is.
ns_json_text() {
  local s="$1" bin
  case "$s" in
    '"'*)
      case "$s" in
        *\\*)
          bin="$(ns_rules_awk_bin)" || {
            s="${s#\"}"
            printf '%s' "${s%\"}"
            return 0
          }
          printf '%s' "$s" | "$bin" -v mode=unquote -f "$_NS_RULES_AWK_FILE"
          ;;
        *)
          s="${s#\"}"
          printf '%s' "${s%\"}"
          ;;
      esac
      ;;
    *) printf '%s' "$s" ;;
  esac
}

# _ns_rules_array <p1> <p2> — the string array at that path as compact JSON.
_ns_rules_array() {
  local rest="$NS_RULES_ROWS" line p1 p2 p3 typ val out="" first=1
  while [ -n "$rest" ]; do
    line="${rest%%"$_NS_RULES_NL"*}"
    case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    p1="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p2="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p3="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    typ="${line%%"$_NS_RULES_TAB"*}"
    val="${line#*"$_NS_RULES_TAB"}"
    if ! { [ "$p1" = "$1" ] && [ "$p2" = "$2" ] && [ -n "$p3" ]; }; then continue; fi
    case "$typ" in s | n | b) ;; *) continue ;; esac
    [ "$first" -eq 1 ] || out="$out,"
    first=0
    out="$out$val"
  done
  printf '[%s]' "$out"
}

# ns_rules_set_block <file> <key> <compact-json> — the same document with one top-level key set
# to that value, sorted and indented, on stdout. Every other key survives byte for byte, including
# one this version does not know: a plugin update fills settings in, it never takes them away.
ns_rules_set_block() {
  local bin
  bin="$(ns_rules_awk_bin)" || return 1
  LC_ALL=C "$bin" -v mode=setblock -v key="$2" -v value="$3" -f "$_NS_RULES_AWK_FILE" <"$1"
}

# ns_rules_get_in <file> <block> <key> — one field of a settings block, as the
# effective scalar, null, or compact JSON for an array. Empty when absent.
ns_rules_get_in() {
  local row typ val
  ns_rules_load "$1" || return 0
  row="$(_ns_rules_row "$2" "$3" "")" || return 0
  typ="${row%%"$_NS_RULES_TAB"*}"
  val="${row#*"$_NS_RULES_TAB"}"
  case "$typ" in
    s | n | b) ns_json_text "$val" ;;
    z) printf 'null' ;;
    a) _ns_rules_array "$2" "$3" ;;
  esac
}

# ns_rules_get <file> <key> — the effective scalar (or compact JSON for an
# object/array). Empty when the key is absent or the file fails closed.
ns_rules_get() {
  local row typ val p1 p2 p3 parts first=1 rest line child_typ child_val
  ns_rules_load "$1" || return 0
  row="$(_ns_rules_row "$2" "" "")" || return 0
  typ="${row%%"$_NS_RULES_TAB"*}"
  val="${row#*"$_NS_RULES_TAB"}"
  case "$typ" in
    s | n | b) ns_json_text "$val" ;;
    z) printf 'null' ;;
    o | a)
      parts=""
      rest="$NS_RULES_ROWS"
      while [ -n "$rest" ]; do
        line="${rest%%"$_NS_RULES_NL"*}"
        case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
        [ -n "$line" ] || continue
        p1="${line%%"$_NS_RULES_TAB"*}"
        [ "$p1" = "$2" ] || continue
        line="${line#*"$_NS_RULES_TAB"}"
        p2="${line%%"$_NS_RULES_TAB"*}"
        line="${line#*"$_NS_RULES_TAB"}"
        p3="${line%%"$_NS_RULES_TAB"*}"
        line="${line#*"$_NS_RULES_TAB"}"
        child_typ="${line%%"$_NS_RULES_TAB"*}"
        child_val="${line#*"$_NS_RULES_TAB"}"
        if [ -z "$p2" ] || [ -n "$p3" ]; then
          continue
        fi
        # A field is carried whatever it holds. Skipping a shape here would drop
        # it from the object silently, which reads as a setting the owner never
        # wrote rather than one this reader could not render.
        case "$child_typ" in
          s | n | b) ;;
          z) child_val=null ;;
          a) child_val="$(_ns_rules_array "$2" "$p2")" ;;
          *) continue ;;
        esac
        if [ "$typ" = a ]; then
          [ "$first" -eq 1 ] || parts="$parts,"
          first=0
          parts="$parts$child_val"
        else
          [ "$first" -eq 1 ] || parts="$parts,"
          first=0
          parts="$parts$(ns_rules_json_key "$p2"):$child_val"
        fi
      done
      if [ "$typ" = a ]; then
        printf '[%s]' "$parts"
      else
        printf '{%s}' "$parts"
      fi
      ;;
  esac
}

# ns_rules_json_key <text> — the key as a JSON string. Keys from this reader
# never carry controls.
ns_rules_json_key() {
  local s="$1"
  s="$(printf '%s' "$s" | sed 's/\\/\\\\/g; s/"/\\"/g')"
  printf '"%s"' "$s"
}

# ns_rules_keys <file> — top-level key names, one per line, file order.
ns_rules_keys() {
  local rest line p1 seen
  ns_rules_load "$1" || return 1
  seen="$_NS_RULES_NL"
  rest="$NS_RULES_ROWS"
  while [ -n "$rest" ]; do
    line="${rest%%"$_NS_RULES_NL"*}"
    case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    p1="${line%%"$_NS_RULES_TAB"*}"
    [ -n "$p1" ] || continue
    case "$seen" in *"$_NS_RULES_NL$p1$_NS_RULES_NL"*) continue ;; esac
    seen="$seen$p1$_NS_RULES_NL"
    printf '%s\n' "$p1"
  done
}

# ns_rules_tool_state <file> <tool> — deny | allow | missing | invalid
ns_rules_tool_state() {
  local row typ val
  ns_rules_load "$1" || {
    printf 'invalid\n'
    return 0
  }
  row="$(_ns_rules_row toolDeny "" "")" || {
    printf 'missing\n'
    return 0
  }
  typ="${row%%"$_NS_RULES_TAB"*}"
  [ "$typ" = o ] || {
    printf 'invalid\n'
    return 0
  }
  row="$(_ns_rules_row toolDeny "$2" "")" || {
    printf 'missing\n'
    return 0
  }
  typ="${row%%"$_NS_RULES_TAB"*}"
  val="${row#*"$_NS_RULES_TAB"}"
  [ "$typ" = s ] || {
    printf 'invalid\n'
    return 0
  }
  val="$(ns_json_text "$val")"
  if [ -z "$val" ]; then
    printf 'allow\n'
  else
    printf 'deny\n'
  fi
}

# ns_rules_tool_deny_json <file> — compact JSON object of toolDeny, or {}.
ns_rules_tool_deny_json() {
  local rest line p1 p2 p3 typ val first=1
  ns_rules_load "$1" || {
    printf '{}'
    return 1
  }
  printf '{'
  rest="$NS_RULES_ROWS"
  while [ -n "$rest" ]; do
    line="${rest%%"$_NS_RULES_NL"*}"
    case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    p1="${line%%"$_NS_RULES_TAB"*}"
    [ "$p1" = toolDeny ] || continue
    line="${line#*"$_NS_RULES_TAB"}"
    p2="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p3="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    typ="${line%%"$_NS_RULES_TAB"*}"
    val="${line#*"$_NS_RULES_TAB"}"
    if [ -z "$p2" ] || [ -n "$p3" ] || [ "$typ" != s ]; then
      continue
    fi
    [ "$first" -eq 1 ] || printf ','
    first=0
    printf '%s:%s' "$(ns_rules_json_key "$p2")" "$val"
  done
  printf '}'
}

# ns_rules_map_parse <json-object> — object-of-strings into NS_RULES_MAP
# (key<TAB>decoded-value lines). Status 1 when the text is not that shape.
ns_rules_map_parse() {
  local src="$1" rows rest line p1 p2 p3 typ val
  if [ "$NS_RULES_MAP_SRC" = "$src" ] && [ -n "$NS_RULES_MAP_SRC" ]; then
    return 0
  fi
  NS_RULES_MAP=""
  NS_RULES_MAP_SRC=""
  rows="$(printf '%s' "$src" | _ns_rules_scan strings -)" || {
    NS_RULES_ERR="invalid tool map"
    return 1
  }
  case "$rows" in
    ERR"$_NS_RULES_TAB"*)
      NS_RULES_ERR="${rows#ERR"$_NS_RULES_TAB"}"
      NS_RULES_ERR="${NS_RULES_ERR%%"$_NS_RULES_NL"*}"
      return 1
      ;;
  esac
  rest="$rows"
  while [ -n "$rest" ]; do
    line="${rest%%"$_NS_RULES_NL"*}"
    case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    p1="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p2="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    p3="${line%%"$_NS_RULES_TAB"*}"
    line="${line#*"$_NS_RULES_TAB"}"
    typ="${line%%"$_NS_RULES_TAB"*}"
    val="${line#*"$_NS_RULES_TAB"}"
    if [ -z "$p1" ] || [ -n "$p2" ] || [ -n "$p3" ] || [ "$typ" != s ]; then
      continue
    fi
    NS_RULES_MAP="$NS_RULES_MAP$p1$_NS_RULES_TAB$(ns_json_text "$val")$_NS_RULES_NL"
  done
  NS_RULES_MAP_SRC="$src"
  return 0
}

# ns_rules_map_has <json-object> <key>
ns_rules_map_has() {
  ns_rules_map_parse "$1" || return 1
  case "$_NS_RULES_NL$NS_RULES_MAP" in
    *"$_NS_RULES_NL$2$_NS_RULES_TAB"*) return 0 ;;
  esac
  return 1
}

# ns_rules_map_msg <json-object> <key> — decoded value, or status 1.
ns_rules_map_msg() {
  local rest line
  ns_rules_map_parse "$1" || return 1
  rest="$NS_RULES_MAP"
  while [ -n "$rest" ]; do
    line="${rest%%"$_NS_RULES_NL"*}"
    case "$rest" in *"$_NS_RULES_NL"*) rest="${rest#*"$_NS_RULES_NL"}" ;; *) rest="" ;; esac
    case "$line" in
      "$2$_NS_RULES_TAB"*)
        printf '%s' "${line#*"$_NS_RULES_TAB"}"
        return 0
        ;;
    esac
  done
  return 1
}

# ns_rules_facts <file> — the policy fact stream _ns_policy_load_rules consumes.
# Status 1 when the file is not the accepted shape.
# _ns_rules_array_json <key> <subkey> — an array of strings rebuilt as compact JSON from the
# indexed rows the reader records. Any element that is not a string fails the whole array closed,
# because a half-read list is worse than an absent one.
_ns_rules_array_json() {
  local i=0 row typ val out=""
  while row="$(_ns_rules_row "$1" "$2" "$i")"; do
    typ="${row%%"$_NS_RULES_TAB"*}"
    val="${row#*"$_NS_RULES_TAB"}"
    [ "$typ" = s ] || return 1
    [ "$i" -eq 0 ] || out="$out,"
    out="$out$val"
    i=$((i + 1))
  done
  printf '[%s]' "$out"
}

# The owner preference blocks the resolved view carries, in the byte order it prints them.
# One list, so the fact stream and the setting names cannot drift apart.
NS_RULES_GROUP_KEYS='archive.automatic
archive.layout
archive.root
archive.templatePath
handoff.detail
handoff.enabled
handoff.language
handoff.sections
handoff.templatePath
handoff.view
recovery.launchScope
receipts.duration
receipts.enabled
receipts.progressMinutes
receipts.progressMode
receipts.progressTokens
receipts.templatePath
receipts.usage
shift.execution
shift.hours
shift.toolingPolicy
shift.verificationProfile'

ns_rules_facts() {
  local k c row typ val present pol pat
  ns_rules_load "$1" || return 1
  for k in forbiddenCommands neverCommitPatterns expectedEmail protectedDirs stallMax watchMinutes; do
    row="$(_ns_rules_row "$k" "" "")" && present=1 || present=0
    if [ "$present" -eq 1 ]; then
      typ="${row%%"$_NS_RULES_TAB"*}"
      val="${row#*"$_NS_RULES_TAB"}"
      case "$typ" in
        s | n | b) ;;
        *) val=null ;;
      esac
    else
      val=null
    fi
    printf 'r\t%s\t%s\t%s\n' "$k" "$present" "$val"
  done
  for c in sudo containers global-packages daemons external-services; do
    if _ns_rules_row elevation "$c" "" >/dev/null; then
      present=1
    else
      present=0
    fi
    pol=""
    row="$(_ns_rules_row elevation "$c" policy)" && {
      typ="${row%%"$_NS_RULES_TAB"*}"
      val="${row#*"$_NS_RULES_TAB"}"
      [ "$typ" = s ] && pol="$(ns_json_text "$val")"
    }
    pol="$(printf '%s' "$pol" | tr '\000-\037\177' ' ')"
    printf 'e\t%s\t%s\t%s\n' "$c" "$present" "$pol"
    row="$(_ns_rules_row elevation "$c" pattern)" || continue
    typ="${row%%"$_NS_RULES_TAB"*}"
    val="${row#*"$_NS_RULES_TAB"}"
    [ "$typ" = s ] || continue
    pat="$(ns_json_text "$val")"
    [ -n "$pat" ] || continue
    pat="$(printf '%s' "$pat" | tr '\000-\037\177' ' ')"
    printf 'p\t%s\t%s\n' "$c" "$pat"
  done
  # The owner's preference blocks, one fact per key, named the way the resolved view names them.
  # These are permanent settings rather than tonight's choices, so a `g` row carries the same
  # three fields an `r` row does and the resolver treats them identically.
  for k in $NS_RULES_GROUP_KEYS; do
    c="${k%%.*}"
    row="$(_ns_rules_row "$c" "${k#*.}" "")" && present=1 || present=0
    if [ "$present" -eq 1 ]; then
      typ="${row%%"$_NS_RULES_TAB"*}"
      val="${row#*"$_NS_RULES_TAB"}"
      case "$typ" in
        s | n | b | z) ;;
        a) val="$(_ns_rules_array_json "$c" "${k#*.}")" || val=null ;;
        *) val=null ;;
      esac
    else
      val=null
    fi
    printf 'g\t%s\t%s\t%s\n' "$k" "$present" "$val"
  done
}

SHA-256: a19329be3a6d9f1dac9aca275e221c674237ca9628cbf700402fd9f3953c3541