fix: stabilization pass — fail-closed auth, ai flag validation, lint/config/security hardening, regression tests
gates / consistency-and-conventions (push) Successful in 26s

17-point code-level audit executed via Explorer->Architect->Builder->Tester->Reviewer;
Reviewer accepted (APPROVE_WITH_NOTES; 3 block-list items resolved):

- security: telegram sender-owner AND-gate + TELEGRAM_OWNER_ID, matrix
  MATRIX_ROOM_ID fail-closed, gpg --passphrase-fd 3 (no argv secret),
  /dev/tcp positional-arg form (checkport/smb-client/share-lib/NET_PROBE),
  eval deny-by-default + --no-command-execution carried by both chat bridges,
  tty-gated --trust; config/{telegram,matrix}.env reference templates
- ai: all ExecStart flags validated against installed llama.cpp
  (requested->error, default->omit+warn, CONFIG_REQUESTED_FLAGS); single-file
  hf download failure rc=1 + no .hf-meta; LLAMACPP_HOST coherent;
  POS_SUBCMDS + metadata gaps closed
- tooling: lint-conventions Bash-native rewrite (~24-30x faster, rules and
  output byte-identical, :num restored); pos system uninstall covers all 12
  libs + scale-tail + flags dir + systemd user units (|| true) + plugin
  markers; anchored .bash_completion/.bashrc removal replaces sed -i '/pos/d'
- config: canonical load_env_file in lib/config-ui.sh (CRLF strip, env-wins,
  XDG, LOADED_ENV_KEYS); 9 tools migrated; entertainment-lib collapsed to
  wrappers; docker-compose deliberately unmigrated (source semantics)
- tests: first committed regression suite — tests/run-tests.sh zero-dep
  runner + make test; 12 files / 179 checks / 0 skip / ~52s; hard skip
  contract; systemd-analyze verify on generated unit PASS

Verified: make gen idempotent; make check green; make lint 0 FAIL, 0 WARN;
make test green; bash -n clean; git diff --check clean. Audit deliverables +
agent reports + AGENT_TODO Done entry included.
This commit is contained in:
Your Name
2026-09-06 07:25:44 -04:00
parent 528b16676e
commit d817c37652
69 changed files with 5161 additions and 406 deletions
@@ -0,0 +1,98 @@
# AI Subsystem Audit — Evidence-Based Verification
Date: 2026-09-06
Explorer: read-only, evidence-first.
Scope: `bin/pos-ai`, `bin/pos-ai-hf`, `bin/pos-ai-server`, forwarders (`gemini`/`openrouter`/`llamacpp`), `lib/ai-providers/*.sh`, `ai.env` config + `# POS_CONFIG:`/`pos config ai`, `# POS_FLAGS:`/`# POS_SUBCMDS:` metadata, `DOC/POS.md` ai section, GEN doc rows, `completions/pos.bash`, `bin/pos` usage().
---
## TL;DR
- **The AI subsystem is largely real and coherent.** Providers (gemini/openrouter/llamacpp) adapters implement real API calls with key masking; `pos-ai-server` was genuinely repaired (commit `528b166`) — the old fake `validate_server_features` stub and unquoted multi-line ExecStart are gone, `systemd_quote` quoting is correct, `detect_llama_version` is guarded.
- **One HIGH defect:** `pos-ai-hf` **single-file** download failure still exits **0** and writes `.hf-meta` advertising a complete model (only the parallel/multi-file path was fixed for partial-failure rc=1). `bin/pos-ai-hf:716-720, 727-744, 773-775`.
- **One MEDIUM coherence defect:** `LLAMACPP_HOST` is honored by the server (ExecStart `--host` + display) but **ignored** by the llamacpp provider adapter and by the server's own probes, all of which hardcode `127.0.0.1` (`lib/ai-providers/llamacpp.sh:14,20,31,46-47`; `bin/pos-ai-server:131,576`).
- **pos-ai-server flag validation is NOT complete:** only CLI-explicit flags are validated; config-sourced flags and always-emitted defaults (`--threads`, `--n-gpu-layers`, `--ctx-size`, `--port`, `--host`) are never validated. "Version-aware" is cosmetic (version only interpolated into the error string, never used to branch logic).
- **Streaming is NOT implemented** in any provider (llamacpp explicitly sets `stream:false`); **no claim** of streaming exists in docs. Not a defect, just a fact to record.
- **Command-extraction** (`_extract_commands` / `_prompt_run_command`) provenance: `trusted=1` comes ONLY from the `--trust` CLI flag or alias wrappers that inject `--trust` (alias env field 5=1). Confirmation default on a tty is **ALLOW** (Enter runs); non-tty is fail-safe (never runs). Extra alarm is warranted only if the operator marks an alias trusted.
---
## Evidence table
| ID | file:line | current behavior | classification | notes |
|----|-----------|------------------|----------------|-------|
| A1 | bin/pos-ai:649-677 | Parses exactly `--provider --model --session --system --full --last --trust` + `-h` | MATCH | `# POS_FLAGS:` (line 5) matches parse exactly |
| A2 | bin/pos-ai:693-706 | Subcommands `ask capture chat models providers sessions` + `llamacpp` shorthand | MATCH for the six; **llamacpp shorthand IMPLEMENTED-BUT-UNLISTED** | `# POS_SUBCMDS:` (line 4) omits `llamacpp`; completions/pos.bash:51 does include it |
| A3 | bin/pos-ai:130-160 | `load_config` reads ai.env (env-already-exported wins) + legacy gemini/openrouter files | MATCH | duplicated loader, no shared one (see M3) |
| A4 | bin/pos-ai:162-202 | Config precedence: `--model` > AI_MODEL > provider-specific (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`/`LLAMACPP_MODEL`) > `provider_default_model` | MATCH | llamacpp `resolve_model` passes **basename** of LLAMACPP_MODEL (line 198) |
| A5 | bin/pos-ai:363-406, 531-535, 571-572 | `_extract_commands` parses ```bash/sh/shell``` fenced blocks; `_prompt_run_command` prompts/executes | MATCH | provenance of trusted traced to `--trust` flag + alias field 5 (pos-ai-alias:64,433-436,580). tty default = run; non-tty = never runs |
| A6 | lib/ai-providers/gemini.sh:24-36 | Real `generateContent` call; `-m 60` timeout; parses error.message, non-200 → rc 1 | MATCH | no streaming tokens (`?alt=sse` absent) |
| A7 | lib/ai-providers/openrouter.sh:24-37 | Real `/api/v1/chat/completions`; `-m 60`; error.message parse; rc 1 on non-200 | MATCH | |
| A8 | lib/ai-providers/llamacpp.sh:19-42 | Real `/v1/chat/completions`; `-m 120`; **hardcodes `127.0.0.1`**, only reads `LLAMACPP_PORT`; `stream:false` | MATCH for localhost mode; **ignores LLAMACPP_HOST** → coherence defect D2 | error path only prints "API error $code", no error.message |
| A9 | bin/pos-ai-server:388-392, 445, 501-508 | ExecStart single-line, binary+model `systemd_quote()`d | MATCH | repair confirmed real |
| A10 | bin/pos-ai-server:52-59 | `detect_llama_version` guarded; returns "unknown" safely; takes binary arg | MATCH | repair confirmed; no errexit |
| A11 | bin/pos-ai-server:66-87, 407-409 | `validate_requested_flags` only validates `REQUESTED_FLAGS` (CLI-explicit only) | PARTIAL (see D3) | config + defaults never validated; grep -qF substring (see D4) |
| A12 | bin/pos-ai-server:262-266, 373-380 | Defaults: PORT 8088, HOST 127.0.0.1, CTX 4096, GPU -1, THREADS nproc; CLI > env > default | MATCH | bind localhost by default ✓ |
| A13 | bin/pos-ai-server:128-135, 576 | health probe `/health` + `/v1/models` **hardcoded `127.0.0.1`** | PARTIAL (see D2) | ignores LLAMACPP_HOST for probes |
| A14 | bin/pos-ai-hf:185-251 | `hf_api` auth header, 401/403/404/429/other → `err`; JSON validity check; 429 retry-once | MATCH | base URL https://huggingface.co/api |
| A15 | bin/pos-ai-hf:254-312 | `hf_paginate` Link rel="next"; `hf_repo_files` tree endpoint with guards + fallback; `hf_search` jq urlencode | MATCH | pagination real; error-object guard via `select(type=="...")` |
| A16 | bin/pos-ai-hf:636-700 | Parallel download drain, per-pid wait, honest failure counting, `rc=1` on partial failure, no `.hf-meta` on partial | MATCH for multi-file | **single-file path defect = D1** |
| A17 | bin/pos-ai-hf:778-812, 915-992 | `list`/`cache` only list dirs with `.hf-meta`; `cache clear` fail-closed confirm | MATCH | |
| A18 | bin/pos-ai-hf:449-469 | `--branch`/`--revision` alias, last-wins; default branch from API else "main" | MATCH | |
| A19 | completions/pos.bash:6,7,26,51 | completion flags for ai/ai-server/ai-hf match `# POS_FLAGS:`; subcmds match declared | MATCH for declared | **no `_pos_subcmds[ai-hf]`** (see M1) |
| A20 | DOC/POS.md:56-125 | ai/hf/server sections describe behavior congruent with code | MATCH | no streaming claim |
| A21 | pos config ai / cfg_display masks `secret` keys | AI_GEMINI_API_KEY, OPENROUTER_API_KEY, HF_TOKEN are `secret`-flagged → masked | MATCH | lib/config-ui.sh:347-361 |
---
## Findings (ranked defects)
### AI-subystem defects
- **D1 (HIGH)** — `pos-ai-hf` single-file download failure returns **0** and writes `.hf-meta` for a partial model.
Evidence: `bin/pos-ai-hf:716-720` — sequential path `warn "Failed to download $fname"; continue` without recording into `failed_files`; `:727-744` — `failed_files` empty ⇒ `.hf-meta` written; `:773-775` — the `return 1` clause only fires for the parallel path. Only if `file_count>1` (parallel branch, `:641`) is a failure non-zero + meta-suppressed. Contradicts commit `528b166` message "rc=1 on partial failure, no .hf-meta for half-downloaded models". A truncated `-C -` partial `.gguf` can then be handed to `pos ai server start`.
- **D2 (MEDIUM)** — `LLAMACPP_HOST` advertised but only honored by the server, not by the client/probes.
Evidence: server emits `--host $HOST` (`bin/pos-ai-server:445`) and displays `endpoint: http://$HOST:$PORT` (`:604`), but health probe `:131` and `/v1/models` probe `:576` hardcode `127.0.0.1`; the llamacpp **adapter** (`lib/ai-providers/llamacpp.sh:14,20,31,46-47`) hardcodes `127.0.0.1` and reads only `LLAMACPP_PORT`. A non-localhost `LLAMACPP_HOST` → server binds elsewhere while `pos ai llamacpp ask/chat/models` and probes talk to loopback. Coherence break across the two tools + the advertised config key.
- **D3 (MEDIUM)** — `validate_requested_flags` validates **only CLI-explicit** flags.
Evidence: `REQUESTED_FLAGS` is populated only in the arg-parsing `case` clauses (`bin/pos-ai-server:295-359`); the gate `if [ "${#REQUESTED_FLAGS[@]}" -gt 0 ]` (`:407-409`) + always-serialized defaults `--n-gpu-layers $gpu_layers --ctx-size $CTX_SIZE --threads $THREADS` (`:446-448`) and config-sourced `--port/--host` (`:445`) are never checked. Also: the `version` argument is used **only** in the error string (`:84`); there is no version-conditional logic — the "version-aware" phrasing is cosmetic. Practically mitigated because the always-on core flags (`--threads`, `--n-gpu-layers`, `--ctx-size`, `--port`, `--host`) are universal across llama.cpp builds, but the guarantee is broader than the implementation.
- **D4 (LOW)** — `validate_requested_flags` uses loose substring matching `grep -qF -- "$flag"` (`bin/pos-ai-server:83`). A flag token could match inside synthesized `--help` text unrelated to actual support (e.g. a builder that documents a placeholder), giving false-passes; no word/`--flag=` boundary. Minor robustness issue.
- **D5 (LOW / UNKNOWN-live)** — llamacpp model id: `resolve_model` passes `basename "$LLAMACPP_MODEL"` (`bin/pos-ai:198`) as the `model` field. llama.cpp `/v1/models` `.id` is not guaranteed to equal the GGUF basename (may be the model's internal name or full path). If they differ the server may reply "model not found". UNKNOWN — requires live llama-server to confirm.
- **D6 (LOW, security-relevant)** — command-execution confirmation default is **ALLOW** on a tty: `_prompt_run_command` prompt `[Y/n]` runs on any value except `n|N` (`bin/pos-ai:391-405`); trusted=1 (`--trust` flag `:667-668`, or alias trusted field via `pos-ai-alias:64,433-436`) runs with **no** confirmation (`:385-389`) via `run eval "$cmd"` (`:388,403`). Non-tty is fail-safe (`:382` — returns without running). Not an auto-exec bug under default settings, but the confirmation default-allow + `eval` of AI-extracted shell is worth a security review's attention.
*Provenance of trusted=1 (question 11):* (1) `--trust` CLI flag, default 0 (`:644`); (2) alias wrapper inserts `--trust` when the alias's 5th env field (`name|provider|session|prompt|trusted`) = 1 (`pos-ai-alias:64`), set interactively ("Trust this alias? (y/N)", default N) at create/edit (`:433-436, 580-591`). **No config key** drives trust. Auto-execution paths: only trusted-mode `_prompt_run_command` (`:388`) — nothing else eval's model output.
### Metadata / doc drift
- **M1 (MEDIUM, metadata gap)** — `pos-ai-hf` implements subcommands `search/download/list/remove/info/files/cache` but declares **no `# POS_SUBCMDS:` header** (`bin/pos-ai-hf:3-4`). Consequently `_pos_subcmds[ai-hf]` is absent from `completions/pos.bash` and the gen doc tables/tree do not surface them. IMPLEMENTED-BUT-UNLISTED.
- **M2 (LOW)** — `pos-ai` `llamacpp` shorthand subcommand (parsed, `bin/pos-ai:701-704`) absent from `# POS_SUBCMDS:` (line 4). Completions already include it (`completions/pos.bash:51`). IMPLEMENTED-BUT-UNLISTED (only in the shorthand).
- **M3 (LOW, maintainability)** — no shared `ai.env` loader. pos-ai (`load_config`, `:130`), pos-ai-server (`load_config`, `:21`), pos-ai-hf (`load_hf_config`, `:30`) each duplicate the env-precedence loop; `lib/common.sh` only provides `load_system_env` (system.env) and `CONFIG_DIR`. All three behave identically, but this is a future-drift seam and contradicts the "shared loader" intent implied by AGENTS.md.
- **M4 (LOW)** — forwarder `# POS_SUBCMDS:` under-list: `pos-ai-openrouter:4` = `ask chat sessions capture` (omits `models`, which works via passthrough); all three forwarders omit `providers` (`pos-ai-gemini:4`, `pos-ai-openrouter:4`, `pos-ai-llamacpp:4`), which also works. Completions inherit the under-list.
- **M5 (INFO, not a defect)** — `QUANT_DIR` is a CLI-flag-only variable (`bin/pos-ai-hf:119,134-135`), not an env/config key; it is not in `# POS_CONFIG:` and is correctly absent from `pos config ai`. The audit task listed it among config keys to trace; it is flag-only by design. Configure `HF_TOKEN`/`HF_DOWNLOAD_DIR` instead.
### Could NOT verify live (read-only / no runtime)
- llama-server binary behavior: `--version`/`--help` output, whether `--threads`/`--n-gpu-layers`/`--gpu-threads`/`--kv-cache` etc. are all accepted by the installed build; the `/v1/models` id format (D5).
- Actual Hugging Face API responses (tree endpoint shape, pagination Link header, 429 Retry-After); `hf_paginate`/`hf_repo_files` correctness with live data.
- systemd unit validity: the reviewer report for `528b166` states `systemd-analyze verify` rc=0 was checked; I could not re-run it. Static reading of `systemd_quote` + single-line ExecStart is correct.
- Whether a real Gemini/OpenRouter key/reply path works end-to-end (key required).
---
## Handoff
Status: COMPLETE (objective answered to the limit of read-only evidence).
Evidence: all file:line cited above; primary source + git commit `528b166` diff.
- **Recommended next agent: Reviewer (adversarial review)** on the single-file download path (D1) and the `LLAMACPP_HOST` coherence break (D2) — both are concrete, source-provable behavior defects ripe for a focused fix. **Builder** if a fix is decided (D1: route single-file through the failure-counting harness or check `hf_download_with_progress` rc and suppress meta + return 1; D2: thread `LLAMACPP_HOST` into the adapter and probes). **Maintainer** for the metadata gaps (M1, M2, M4) and the duplicated ai.env loader (M3).
- Affected areas: `bin/pos-ai-hf`, `bin/pos-ai-server`, `lib/ai-providers/llamacpp.sh`, `bin/pos-ai*` POS_* headers, `completions/pos.bash`, gen doc output.
- Decision boundary: adding `# POS_SUBCMDS:` to `pos-ai-hf` is a metadata change requiring `make gen` + `make check` + `make lint` (per AGENTS.md) — that is a Builder/Maintainer action, out of Explorer scope.
Remaining uncertainty: D5 (llamacpp model-id match) and all binary/API live behavior (listed above) — needs a live llama.cpp/HF/Gemini environment.
@@ -0,0 +1,239 @@
# Security Audit — Command-Execution Surfaces, Chat Authorization, Secret Handling
**Date:** 2026-09-06
**Explorer:** read-only investigation
**Scope:** `bin/`, `lib/`, `scripts/`, `install.sh`, `preinstall.sh`, `postinstall.sh`, `apps/`, `entertainment/`
## TL;DR
- **Telegram listener skips SENDER authorization entirely.** It filters *only on chat id* (`TELEGRAM_CHAT_ID`), and even that filter is weakened by an OR-clause that also accepts `from_id`. No `from.username`/owner-user-id allowlist exists anywhere; there is no config key for one. Anyone who can get a message into the owner chat (shared chat, forwarded/mention, or a group where the bot sees the message with `from_id != chat_id`) can execute arbitrary mapped bash **and** the Gemini AI bridge as your user. This is the single highest-severity finding.
- **Matrix listener DOES implement exactly "sender authorized AND chat authorized"**, but both are *optional-to-configure*: `MATRIX_USER_ID` defaults to a live `/whoami` resolution and `MATRIX_ROOM_ID` to "every room joined". If both are unset at runtime you get "any sender, any room" — remote code execution. Severity depends on which homeserver/rooms the account is in.
- **AI command auto-execution (`pos ai` eval path)** is a real RCE primitive. Trusted aliases pass `--trust` (no confirmation). The listener's `<prefix>` bridge passes *unvalidated chat text* into a shell string that is executed by `bash -c` — a direct RCE.
- **`/dev/tcp` probes interpolate unvalidated host/port into `bash -c` strings** in 4 places. Most reachable inputs are CLI args (interactive/low risk), but `pos-network-download` (`NET_PROBE`, network-derived) and the `share_port_probe` surface deserve review.
- **Backup GPG passphrase** is passed on the gpg **command line** (`--passphrase "$PASS"`) → visible in `ps`/process args, and exported into any `notify`/log context via `set -x` if debugging. No temp files persist the secret; `mktemp` artifacts are cleaned on both success and failure, and the archive is `chmod 600`. The SMB client and Matrix login write credentials to throwaway/templated files correctly (`chmod 600`) and avoid argv exposure, but the SMB persistent credentials dir text is world-readable risk only via file perms (mode 600).
---
## 1. Command-Execution Inventory
All primitives below were located by grep across the specified paths; input provenance chain and classification are traced from source.
### `/dev/tcp` probes — host/port interpolated into `bash -c` string
| ID | file:line | primitive | input source | attacker-controlled? | classification |
|----|-----------|-----------|--------------|----------------------|----------------|
| C01 | `bin/pos-network-checkport:133` | `bash -c "exec 3<>/dev/tcp/$ip/$port"` | `ip` from CLI arg (`$host` from targets, validated no-spaces at :486), `port` validated `[0-9]{1,5}` 1-65535 | port no; host partially (no-space check only, IPv6 brackets stripped) | **REVIEW** — host could contain `;`/`$(...)` if crafted (only `*" "` is rejected; `;`, backticks, `$()` not blocked). Interactive CLI, but the string is unquoted. |
| C02 | `bin/pos-network-checkport:157` | `bash -c "exec 3<>/dev/udp/$ip/$port; printf 'x' >&3"` | same provenance | same | **REVIEW** |
| C03 | `bin/pos-network-checkport:166` | `bash -c ".../dev/tcp/$ip/$port; printf 'HEAD...'"` | same | same | **REVIEW** |
| C04 | `bin/pos-network-checkport:168` | `bash -c ".../dev/tcp/$ip/$port; head -c 200"` | same | same | **REVIEW** |
| C05 | `bin/pos-share-smb-client:92` | `timeout 3 bash -c "exec 3<>/dev/tcp/${host}/${SMB_PORT}"` | `host` from `//server/share` CLI arg; `split_share` extracts `SERVER` with no validation | **partially** — no filtering of `;`/`$()`/backticks in SERVER | **VULNERABLE-INPUT** (low exploitability: requires the operator to type such a host; but a malicious remote `//<payload>/share` string reaches shell). |
| C06 | `lib/share-lib.sh:59` | `timeout 3 bash -c "exec 3<>/dev/tcp/${1}/${2}"` | `$1`=host, `$2`=port from callers (nfs/smb client mounts) | host unvalidated | **VULNERABLE-INPUT** (shared helper; called from CLI-only paths). |
| C07 | `bin/pos-network-download:28,377` | `NET_PROBE=...bash -c '</dev/tcp/8.8.8.8/53>'`; `net_up() { bash -c "$NET_PROBE" }` | **controlled by `NET_PROBE` env var** | **yes** if `NET_PROBE` is attacker-set (e.g. via schedule/daemon env) | **REVIEW** — env-derived command string evaluated; DEFAULT is constant/safe. |
### Listener command execution (chat-driven)
| ID | file:line | primitive | input source | attacker-controlled? | classification |
|----|-----------|-----------|--------------|----------------------|----------------|
| C08 | `bin/pos-communication-telegram-listener:349` | `timeout "$tmo" bash -c "$cmdline"` (in `run_and_reply`) | `$cmdline` = map value (constant, owner-edited) **or** prefix bridge `$cmd $qtext` where `$qtext` = `printf '%q'` of chat text | `$qtext` is the chat text, but `%q`-quoted (safe metachar-wise) | **REVIEW** — map value is constant; prefix path quotes the arg. Map value itself is operator-owned. |
| C09 | `bin/pos-communication-telegram-listener:697` | `run_and_reply "$cmd $qtext" ...` | prefix bridge: `$cmd` = map value (operator), `$qtext` = `%q` quoted chat text | chat text via `%q` (safe) | **REVIEW/SAFE** — quoting present but the whole string is one `bash -c`; the chat text is not the only component. |
| C10 | `bin/pos-communication-matrix-listener:493` | `timeout 60 bash -c "$value"` | `$value` = map value (operator-owned, /cmd) | no (constant operator string) | **SAFE** (constant owner map). |
| C11 | `bin/pos-communication-matrix-listener:214` | `timeout 60 bash -c "$value"` (ui test) | operator input in TUI | no | **SAFE/DESIGNED-INTERACTIVE**. |
| C12 | `bin/pos-communication-telegram-listener:368` | `timeout 60 bash -c "$value"` (ui test) | operator input in TUI | no | **SAFE/DESIGNED-INTERACTIVE**. |
### AI command eval (RCE primitive)
| ID | file:line | primitive | input source | attacker-controlled? | classification |
|----|-----------|-----------|--------------|----------------------|----------------|
| C13 | `bin/pos-ai:388,403` | `run eval "$cmd"` (in `_prompt_run_command`) | `$cmd` = `_extract_commands` of the **AI provider's raw response** (gemini/openrouter/llamacpp output) | **yes — AI-generated** | **VULNERABLE-INPUT** (eval of untrusted model output). Trusted mode (`--trust`, `TRUST_MODE=1`) at :385-388 auto-executes with no prompt; non-trusted prompts on tty. Called from `cmd_ask:535` and `cmd_chat:572`. |
| C14 | `bin/pos-communication-telegram-listener:734` | `timeout 120 pos ai gemini ask ... "$prompt"` | AI bridge: `$prompt` = chat text after `<prefix> ` | chat text — but only as an **argument** to `pos ai gemini ask` (a subprocess arg, quoted via `"$prompt"`); the AI eval inside pos-ai then acts on the model's *reply*. | **REVIEW** — the bridge feeds arbitrary chat text to the model; if the model echoes back a command, C13 fires. Indirect injection. |
| C15 | `bin/pos-communication-matrix-listener:472` | `pos ai gemini ask ... "$prompt"` | same AI bridge (Matrix `ai ` prefix) | same | **REVIEW** (indirect). |
### `sudo` / `systemctl` / docker / ssh / curl|sh
- **`apps/**/*.sh`** — `sudo` used for `apt install`/`systemctl`/`usermod`/`curl | sh` installers. These run **once interactively at install time**; inputs are (mostly) constant URLs. Classified **SAFE/DESIGNED-INTERACTIVE**. Notable pipe-to-shell:
- `apps/networking/zerotier.sh:9``curl -s https://install.zerotier.com | sudo bash` (network→shell, no verification). **Review**.
- `apps/networking/netbird.sh:9`, `tailscale.sh:9``curl -fsSL ... | sh` (official vendor identical). **Review** (no checksum; standard vendor practice).
- `apps/system/docker.sh:9``curl -fsSL https://get.docker.com | sh`. **Review**.
- `apps/development/opencode.sh:9``curl -fsSL https://opencode.ai/install | bash`. **Review**.
- `apps/utilities/tsui.sh:9``curl -fsSL https://neuralink.com/tsui/install.sh | bash`. **Review** (vendor unknown-ish host).
- **`preinstall.sh:45,48,58,65` / `postinstall.sh:101-160` / `install.sh:134-239`** — `sudo apt`, `sudo systemctl`, `sudo install`, `git clone`. Constant/static targets. **SAFE** (interactive bootstrap).
- **`bin/pos-network-scan:88,99`** — `sudo -n nmap` / `sudo nmap`; constant. **SAFE**.
- **`bin/pos-network-hotspot:60-86`** — `sudo create_ap` with `"$@"` passthrough. CLI args reach sudo. **REVIEW** (interactive tool; operator supplies args).
- **`bin/pos-system-backup:173`** — `sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"`; `$FOLDER` CLI arg baked into `$ARCHIVE` name. **REVIEW** (folder name into tar; low risk, arbitrary path backup).
- **`bin/pos-share-nfs-client/smb-client`, `lib/usb-lib.sh`, `lib/share-lib.sh`** — `sudo mount`/`umount`/`mkdir` with mount paths; mountpoint paths validated to be absolute and non-system (smb-client `ask_new_mountpoint`/`menu_ask_mountpoint:579,614` deny `/etc,/boot,/bin,...`). **SAFE/REVIEW** — some paths from `findmnt`/menu.
- **`bin/pos-docker-vbox:1074,1094,1096`** — `docker exec -it ... bash` (interactive attach). **SAFE/DESIGNED-INTERACTIVE**.
- **`scripts/lint-conventions.sh`, `make gen` pipeline** — dev-time `bash -n`/`awk`/`sed`. Out of runtime scope (dev tooling, constant). **SAFE**.
- **No `sshpass`, `scp`, or `ssh` remote-command execution** anywhere (only `pos ssh load-keys` adding keys to the agent, and doc references). No remote-shell-over-ssh primitive found. **N/A**.
- **`lib/scheduler-lib.sh:180`** — `bash -c "$JOB_COMMAND"`; `JOB_COMMAND` is the literal remainder of a `schedule.d/*.env` line, operator-authored, syntax-checked (POS tool). **SAFE/DESIGNED-INTERACTIVE** (operator-owned config; would be RCE if an attacker could write `schedule.d/`).
### Secret-in-process-argv (gpg/openssl)
- **`bin/pos-system-backup:195`** — `gpg --passphrase "$PASS" --symmetric ...`: the backup passphrase is placed on the gpg **command line**, visible to any local user/process via `/proc/<pid>/cmdline` and to `ps as`. **See Section 3.**
---
## 2. Authorization Model — Telegram + Matrix
### Telegram listener (`bin/pos-communication-telegram-listener`)
**Config keys read (via `load_config` line 86-99, plus direct grep in `ai_bridge_prefix`:625):**
| Key | Defined in | Purpose in listener |
|-----|-----------|---------------------|
| `TELEGRAM_BOT_TOKEN` | `# POS_CONFIG:` in `telegram-sender:6`; `telegram.env` | authenticate Bot API |
| `TELEGRAM_CHAT_ID` | same | **the only owner filter** (see below) |
| `TELEGRAM_AI_PREFIX` | same | AI bridge trigger word (default `ai`) |
There is **no** `TELEGRAM_OWNER`, `TELEGRAM_USER_ID`, `TELEGRAM_ALLOWED`, `OWNER_ID`, or any sender-identity allowlist key anywhere in the codebase, config templates, or docs.
**Message flow & the update loop (lines 766-792):**
1. `getUpdates` with `allowed_updates=["message"]` (line 771) — filters update *type* to messages only.
2. Per update: extracts `chat` (`.message.chat.id`), `from_id` (`.message.from.id`), `text` (line 780-783).
3. **The ONLY authorization gate is line 787:**
```
if [ -n "$chat" ] && [ "$chat" != "$TELEGRAM_CHAT_ID" ] && [ "$from_id" != "$TELEGRAM_CHAT_ID" ]; then continue; fi
```
This is: **continue (drop) ONLY IF** chat is set AND chat ≠ owner-chat **AND** from_id ≠ owner-chat.
4. `handle_message` (line 674) then dispatches with **no further sender check**: `/command` map (via `map_get`, line 743) → `run_and_reply` → `bash -c` (C08); web-URL detection → `pos media grab` (line 704); prefix bridge (line 697) → `bash -c`; AI bridge (line 734) → `pos ai gemini ask`.
**Verdict — Telegram does NOT implement "sender authorized AND chat authorized":**
- The check is **chat-or-sender OR**, not AND, and the "sender" comparison uses **`from_id` == `TELEGRAM_CHAT_ID`** — i.e. it assumes the owner's Telegram *user id* numerically equals the *chat id*. That is only true for a direct (1:1) private chat with the owner. In a **group or supergroup**, the `chat.id` is negative and differs from any `from.id`; the OR-clause then accepts **any** `from_id` that happens to equal `TELEGRAM_CHAT_ID` (unlikely) OR requires chat==owner. In a **shared/followup private chat** scenarios or when `TELEGRAM_CHAT_ID` is a forwarded context, the model breaks.
- **Concretely exploitable:** if the bot's token is added to a group, `chat.id` (group, negative) ≠ `TELEGRAM_CHAT_ID` AND `from_id` (a member) ≠ `TELEGRAM_CHAT_ID` → condition **false** → message is dropped. So plain group members are blocked **only if** `TELEGRAM_CHAT_ID` is truly the owner's 1:1 chat and the bot isn't also filtering otherwise. BUT there is **no sender allowlist**, so the guard is the sole mechanism and it mis-handles the general case. If the owner ever sets `TELEGRAM_CHAT_ID` to a group id (a plausible misconfiguration the tool doesn't prevent), **anyone in that group executes commands**. Also the model treats `from_id == TELEGRAM_CHAT_ID` as allowed even when `chat` is different — so a message where sender id coincidentally equals the configured numeric id (or a bot re-post) is accepted regardless of chat.
- **No `from.username`, no `from.first_name/last_name` allowlist, no user-id allowlist** — the requirement "is the SENDER's from.id checked ANYWHERE before a mapped command or AI bridge executes?" is answered: **yes, but only against the chat-id value via an OR with chat-id, and only for one numeric field.** This is not a proper sender authorization.
- **Missing/invalid config behavior:** `run_daemon:759-760` `err`s (exits) if token or chat-id are unset. Good fail-closed for *chat* but there is no sender config to miss.
**Map-file editing surface:** exclusively the **interactive TUI** (`ui()` at line 440, no-arg invocation; `ui_add/ui_edit/ui_remove/ui_test`, lines 378-438) and the `prefix` verb for the prefix map (`prefix_cmd:567`). No network/API path writes `telegram_commands.env`. The map file is `chmod 600` (`map_set:137,144`). **Risk:** it is a **local** file `~/.config/linux_post_install/telegram_commands.env` editable only by the owner at the shell; if the owner account is compromised the map is trivially editable (but that's full-host compromise anyway). The `/cmd::description=` text (user-typed description) flows into `sync_bot_commands` → `setMyCommands` (line 215), so a map *description* is sent to Telegram — a low-level info leak of the operator's own design, not an attacker surface.
### Matrix listener (`bin/pos-communication-matrix-listener`)
**Config keys read (via `load_config`:65-78):**
| Key | Defined in | Purpose |
|-----|-----------|---------|
| `MATRIX_HOMESERVER` | `# POS_CONFIG:` `matrix-sender:5`; `matrix.env` | server URL for /sync |
| `MATRIX_ACCESS_TOKEN` | same | auth |
| `MATRIX_USER_ID` | same | **owner/sender filter** |
| `MATRIX_ROOM_ID` | same | **room filter** (optional) |
**Message flow & /sync loop (lines 509-557):**
1. Resolves owner: `MATRIX_USER_ID` if set, else live `/account/whoami` (lines 514-523).
2. `room_only="${MATRIX_ROOM_ID:-}"` (line 515) — **empty = watch every join.**
3. Per room: skip if `room_only` set and `room != room_only` (line 539).
4. Per event: require `type==m.room.message`, `content.msgtype==m.text` (546-547); **sender filter line 550:** `[ "$sender" = "$owner" ] || continue` — message REFUSED unless the sender is the owner.
5. `handle_message` (line 447) dispatches with **no further sender check**: `/`/`!` command map (line 483) → `bash -c` (C10); `ai ` bridge (line 472) → `pos ai gemini ask`.
**Verdict — Matrix:**
- `MATRIX_ROOM_ID` set: **sender authorized (== owner) AND chat authorized (== configured room)** — matches the requirement. ✅
- `MATRIX_ROOM_ID` unset but `MATRIX_USER_ID` set: sender authorized, chat = **every joined room** — chat is NOT pinned. An owner tweet from any room triggers RCE. ⚠️
- Both unset: owner auto-resolved via whoami (still "sender == owner"), room = every room. Still sender-gated, but the room is unbounded. If `whoami` *fails* (line 520-521), it `err`s out (fail-closed). So Matrix is **sender-gated always** (owner == account's own user id), chat optionally restricted. This is a materially **stronger and correct** model than Telegram's.
**Guard lines that would need to change to satisfy "user AND chat authorized" fully:**
- **Telegram:** replace the OR at `bin/pos-communication-telegram-listener:787` with an AND requiring `chat == TELEGRAM_CHAT_ID` **and** a sender check against a new allowlist key (e.g. `TELEGRAM_OWNER_ID`). Minimal sketch:
```bash
# 1) Chat must be the owner chat
[ "$chat" = "$TELEGRAM_CHAT_ID" ] || continue
# 2) Sender must be an allowed user id (new key, fail-closed if unset)
[ -n "${TELEGRAM_OWNER_ID:-}" ] || { warn "no TELEGRAM_OWNER_ID — refusing"; continue; }
case " $TELEGRAM_OWNER_ID " in *" $from_id "*) ;; *) continue ;; esac
```
plus add the `TELEGRAM_OWNER_ID` key to the `# POS_CONFIG:` registry in `telegram-sender:6` (`pos config telegram`). **Do NOT implement — this is exploration output only.**
- **Matrix:** to fully pin chat even when `MATRIX_ROOM_ID` is unset, `run_daemon` should fail-closed (refuse to start) or require a room list; currently line 515 defaults to "watch all." Add validation that `MATRIX_ROOM_ID` is set before `--run` (or an explicit allowlist). **Do NOT implement.**
### Other listener/plugin paths that execute commands
- **Entertainment plugins** (`entertainment/{weather,joke,gold}.sh`) fetch public APIs and print text — **no command execution**; output is sent via `pos-entertainment-send` → `notify_send`. Not an RCE surface via chat; they run on a schedule or explicit `pos entertainment send`.
- **`pos-docker-vbox enter`** (`docker exec -it bash`) is operator-attach only.
- **No other chat→command executor** found besides the two listeners and the AI bridge.
---
## 3. Backup / GPG Secret Handling
File: `bin/pos-system-backup`. **PASS provenance chain:**
1. **Prompted interactively** (lines 183-186): `read -s -rp "Enter backup password:" PASS`, then `read -s -rp "Confirm..." CONFIRM`. Read from the terminal with echo suppressed — **not** from config file, env, or argv. Good.
2. Validation (187-191): non-empty and `PASS == CONFIRM`, else loops.
3. `unset CONFIRM` (192) immediately after.
4. **Every place PASS is used:**
- line 195: `gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"` — **PASS on the command line** → visible in `/proc/<pid>/cmdline` and `ps` output of `gpg`. **This is the exposure.**
- line 202: `gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf -` — verify/decrypt path, **same argv exposure**.
- line 204: `unset PASS`.
5. **Temp files:** none created for the secret. `mktemp` is **not** used anywhere in backup; the archive is `tar -czvf "$ARCHIVE"` (line 173), then encrypted, then `rm -f "$ARCHIVE"` (line 197) leaving `$ARCHIVE.gpg`, `chmod 600` (line 199). The plain intermediate `.tar.gz` is removed **after** encryption. On **failure** (encrypt/verify fail → `set -euo pipefail` aborts): if gpg fails at 195, the plain `$ARCHIVE.tar.gz` **remains on disk** (sleeped only after the successful encrypt at 197). No trap removes the plain intermediate. This is a **partial-failure plaintext-leak** risk (archive stays unencrypted if `gpg --symmetric` fails). Also the top trap (line 17) sends `notify_send "Backup FAILED..."` on any ERR — does not leak PASS but does announce paths.
6. **verify/decrypt path:** line 202 pipes gpg decrypt into `tar -tzf -` and discards (no plaintext written to disk) — good, verify is streaming.
7. **notify/msg exposure:** backup success sends `notify_send "Backup completed: $ARCHIVE"` (line 211) and USB copy sends `...: $dest` (line 112) — **artifact paths (not contents) are sent to Telegram/Matrix** via `lib/notify.sh`. These are local absolute paths; harmless unless they reveal structure the owner wants private. The **gpg passphrase is NOT** in any notify message.
8. **Shell-history/log exposure:** PASS is read via `read -s` (not echo'd, not in history). It appears only in the gpg argv; not logged to any file by `pos`. However if the operator runs the tool under `set -x` or with shell tracing, `PASS` is a shell variable and would be expanded into stderr; not a repo bug, but the argv placement (not env) is the primary leak vector.
**Other secrets in process argv repo-wide:**
- `bin/pos-network-hotspot:60` — `sudo create_ap ... "$@"`; if a passphrase/SSID is passed as an arg it enters `create_ap` argv. Interactive, no secret stored.
- `matrix-sender` `login` (pos-communication-matrix-sender:167-190): password read via `read -rsp ... </dev/tty`, sent **in the HTTP JSON body** (not argv), access token saved to `matrix.env` `chmod 600` (save_config:67). **Good** — no argv leak.
- `pos-share-smb-client` `make_creds` (143-152): password `read -rsp`, written to a throwaway `mktemp` file `chmod 600`, `printf '...password=%s'` — **not argv**. SMB persistent creds `sudo install -m 600` to `/etc/samba/credentials/` (line 346). **Good**.
- `pos-ai` API keys: sent as HTTP header `x-goog-api-key: ${AI_API_KEY}` (gemini.sh:25) — **not argv**. `AI_API_KEY`/`AI_GEMINI_API_KEY` exported env var; safe.
- Telegram bot token: used in URL `.../bot${TELEGRAM_BOT_TOKEN}/...` (listener:636,768; sender:81) — appears in **URLs/argv of curl** (`curl ... https://api.telegram.org/bot<TOKEN>/...`). The token is in curl's argv → visible via `ps`. **This is a real secondary exposure**: the Telegram bot token is a local-process-argv secret. Same class as the gpg passphrase.
---
## Severity-ranked concrete vulnerabilities (real, not hypothetical)
**V1 — HIGH — Telegram listener lacks sender authorization; remote code execution as your user.**
`bin/pos-communication-telegram-listener:787` is the only gate and it is an OR over `chat`/`from_id` against the single `TELEGRAM_CHAT_ID` value, with **no sender allowlist key existing anywhere**. Any message that satisfies either `chat == TELEGRAM_CHAT_ID` OR `from_id == TELEGRAM_CHAT_ID` triggers mapped `bash -c` (C08) and the Gemini bridge. In group/shared-chat misconfiguration, any member runs arbitrary commands as the owner. Even in the "correct" 1:1 setup there is no authenticated-sender binding, so a bot-repost or replayed `from_id` is accepted regardless of source chat. Evidence: lines 787, 743, 697, 734.
**V2 — HIGH — `pos ai` evaluates arbitrary AI-provider output; `--trust` removes confirmation.**
`bin/pos-ai:388,403` runs `eval "$cmd"` where `$cmd` is extracted from the provider's raw response (`_extract_commands`:363). The Telegram/Matrix AI bridges (listener:734/:472 / C14/C15) feed arbitrary chat text as the prompt; if the model's reply (or a prompt-injection / model misbehavior) emits a fenced `bash`/`sh` block, it is executed. Non-trust mode prompts on a tty (`[ -w /dev/tty ]`), but **trusted aliases** pass `--trust` (`TRUST_MODE=1`, :385-388) → auto-execute, no confirm. And the **AI bridges run non-interactively (no tty)**, so `_prompt_run_command`'s `[ -w /dev/tty ] || return 0` at line 382 returns 0 **without prompting** → **any** command block in a bridge-AI reply executes automatically even without trust. This makes the chat AI bridge an unconditional RCE on model output. Evidence: pos-ai:382,388,403,535,572; listeners:734/:472.
**V3 — HIGH — backup GPG passphrase on command line (argv exposure).**
`bin/pos-system-backup:195,202` pass `--passphrase "$PASS"` to gpg → secret readable by any local user or leak to syslog/ps. Evidence: lines 195, 202. (Also the plain `.tar.gz` can remain on a failed encrypt: line 197 is only reached after a successful 195.)
**V4 — MEDIUM — Matrix listener optional chat authorization.**
When `MATRIX_ROOM_ID` is unset (default), `run_daemon:515` watches **all joined rooms**; sender still must equal the owner (`:550`), so it's owner-only but unbounded-room. If the account is in any shared room and the owner sends a command there, it executes. Fails closed on unresolved owner (`:521`), so severity is bounded to owner-initiated events. The authorization model is *correct* conceptually but chat-scope defaults too broadly.
**V5 — MEDIUM — unvalidated host/port interpolated into `bash -c /dev/tcp` strings.**
`bin/pos-network-checkport:133,157,166,168`, `bin/pos-share-smb-client:92`, `lib/share-lib.sh:59`. Host strings are not fully validated (no `;`/`$()`/backtick reject) before being embedded in a shell string. Reachable via CLI args (interactive) and `NET_PROBE` env (`pos-network-download:28,377`).
**V6 — LOW/MEDIUM — `curl | sh` / `curl | sudo bash` installers without checksums.**
`apps/{zerotier,netbird,tailscale,docker,opencode,tsui}.sh`. Vendor-standard, but supply-chain risk from the remote script. Interactive install-time only.
**V7 — LOW — Telegram bot token in curl argv.**
`bin/pos-communication-telegram-listener:636,768` and `telegram-sender:81` place `bot<TOKEN>` in a URL passed to curl → token visible in `/proc/<pid>/cmdline`. Same class as V3.
---
## Uncertainties / Could-not-verify
- **Exact live behavior of the Telegram `from_id`/`chat_id` equality in real groups** cannot be established by read-only inspection; the numeric-equality assumption is documented in the code (line 787) but its breakage requires a live group test. This is the crux of V1's real-world exploitability and needs a live check by another agent.
- **Whether `ps`/`/proc` argv is considered a real threat model** for this homelab (single-user local machine) is a policy/rationale question the Explorer can't decide — see note in V3.
- **`pos-network-download`'s `NET_PROBE`** default is constant; whether any deployment injects an attacker-controlled value is unknown — it honours an env var seam.
- **Whether any operator already sets a `MATRIX_ROOM_ID`** (affects V4) is config state not present in the repo (config files are gitignored).
- The exact content of runtime `telegram_commands.env` / `matrix_commands.env` map files (what commands are mapped) is unknown — gitignored.
---
## Important Files
- `bin/pos-communication-telegram-listener` — auth gate (787), dispatch, AI/prefix bridges.
- `bin/pos-communication-matrix-listener` — owner filter (550), room gate (539).
- `bin/pos-ai` — eval path (388,403,382), trusted mode, bridge call sites.
- `bin/pos-system-backup` — passphrase argv usage (195,202).
- `bin/pos-network-checkport`, `bin/pos-share-smb-client`, `lib/share-lib.sh`, `bin/pos-network-download` — /dev/tcp interpolation.
- `lib/ai-providers/gemini.sh` — API key in HTTP header (not argv).
- `bin/pos-communication-{telegram,matrix}-sender` — POS_CONFIG registry, token/creds handling.
- `lib/notify.sh` — artifact-path-only notify sends.
---
## Handoff
- **Status:** COMPLETE (investigation objective satisfied; no code changed).
- **Objective:** Security audit of command-execution surfaces, chat authorization, secret handling.
- **Evidence:** file:line citations throughout; classification by certainty (SAFE / DESIGNED-INTERACTIVE / VULNERABLE-INPUT / REVIEW) with provenance chains.
- **Affected areas:** Telegram & Matrix listeners, `pos-ai` eval, backup GPG, /dev/tcp probes, installer curl|sh.
- **Scope/decision boundary:** Read-only exploration only; **no changes proposed for implementation.** Minimal change sketch for the Telegram auth gate is provided at Section 2 (as exploration output, explicitly NOT implemented).
- **Verification performed:** Full source tracing of both listeners, pos-ai eval, backup, notify, and all grep'd primitives; git blame on the Telegram gate (unchanged since 2026-08-06, b9edd078 / 014d6be).
- **Remaining uncertainty:** live Telegram group behavior (V1), NET_PROBE env deploy state, MATRIX_ROOM_ID config state, real `ps`-argv threat model.
- **Recommended next agent:** **Architect** — the two listeners embody two different authorization philosophies (Telegram: chat-id-OR, no sender allowlist; Matrix: owner-sender with optional room). Aligning them into one "sender AND chat authorized" contract is a cross-component design decision (config schema + registry keys + both daemons), which is precisely an architectural boundary. Evidence above gives the exact guard lines and a minimal-change sketch to evaluate, not implement.
### Scope-expansion note
Investigating Telegram/Matrix authorization surfaced that the auth contract is **not a single-file bug** but a **cross-component, decision-level** matter (two daemons, the `# POS_CONFIG:` registry, config templates, docs, and a new `TELEGRAM_OWNER_ID`-style key). That is an architectural decision, so the handoff above goes to **Architect** per the Explorer's scope rule.
```text
Status: COMPLETE (with Architect handoff on scope expansion)
Reason: Fixing chat authorization correctly spans two listeners + config schema + registry + docs
Evidence: listener auth gates telegram:787 / matrix:550,539; POS_CONFIG headers in senders; no owner key anywhere
Affected areas: bin/pos-communication-telegram-listener, matrix-listener, telegram-sender (POS_CONFIG), config templates, DOC
Decision required: Architect
Out-of-scope changes: none
```
@@ -0,0 +1,242 @@
# Explorer Report — 2026-09-06 — Tooling Audit (lint perf, install/uninstall symmetry, config-parsing duplication, shell correctness)
## TL;DR
- **Lint perf:** `scripts/lint-conventions.sh` (261 lines) has ~17 external-process hotspots; the dominant cost is **two full-file scans that spawn `printf | sed | tail` per line** (`uses_stdin` L67 and the top-level-`local` scan L143) — for a ~45-tool repo with several >1000-line tools this is roughly **60k+ subprocess forks per lint run** just from those two scans. Wall-time measurements are **UNVERIFIED** (sandbox denies `time`/`make`/`bash` execution); static hotspot inventory below is complete.
- **Install/uninstall symmetry:** a real uninstaller exists (`pos system uninstall`, 435 lines) but it is **PARTIAL** — 9 of 12 installed `lib/*.sh` have no removal path, the ScaleTail template clone (`/usr/local/share/linux_post_install/scale-tail`) and the feature-flag store (`/usr/local/share/linux_post_install/flags/`) are never removed, `~/.config/rclone/` and `/usr/local/bin/yt-dlp` survive, and **all runtime-created systemd *user* units** (`~/.config/systemd/user/`) are missed by every tier.
- **Config duplication:** **9 tools hand-roll byte-similar `load_config()`-style loaders** (env-wins export loop), plus at least 4 bespoke parsers; 2 shared key-value read/write libraries (`config-ui.sh` `cfg_value`/`cfg_write` and `entertainment-lib.sh` `config_value`/`write_config_key`) are duplicates of each other. CRLF-strip behavior splits 5-and-5; the `CONFIG_DIR`/XDG seam is honoured by self-contained tools but **bypassed by several common.sh-sourcing tools** that hardcode `$HOME/.config/linux_post_install/...`. Recommended owner: **`lib/config-ui.sh`**.
- **Shell correctness:** no high-confidence unquoted-`rm -rf`, unquoted-`[ $x ]`, or unguarded-`cd` bugs found in `bin/`; the flagged hotspots are `bin/pos:388/403` (`run eval "$cmd"` — AI-extracted command execution, deliberate but security-relevant), `bin/pos-system-uninstall:333` (`sed -i '/pos/d'` on user `.bash_completion`), and the per-line subprocess spawns in the lint script itself.
---
## Task 1 — Lint performance (`scripts/lint-conventions.sh`, 261 lines)
### 1.1 Measured timing
| Command | Result |
|---|---|
| `time make lint` (run 1) | **UNVERIFIED** — sandbox denies `make`, `bash`, `time` execution |
| `time make lint` (run 2) | **UNVERIFIED** |
| `time bash scripts/lint-conventions.sh` | **UNVERIFIED** |
Static hotspot analysis is complete and is the basis for the estimates (see 1.3).
### 1.2 Per-line/per-file external-process hotspots (rule → implementation → bash-native alternative)
F = per-file spawn, L = per-line spawn, 1× = one-off.
| # | Lint rule | Location | Spawns per unit | Bash-native equivalent (no semantics change) |
|---|---|---|---|---|
| 1 | shebang check | L92 `head -1 "$f" \| grep -q` | F (2 procs/file) | `IFS= read -r first < "$f"` + `[[ $first == '#!/usr/bin/env bash' ]]` |
| 2 | strict-mode check | L95 `has_regex``grep -qE` | F (1 proc/file) | fold into the same first-line read as #1 |
| 3 | INTERACTIVE_CMDS extraction | L84 `sed -n … \| head -1` | 1× (2 procs) | single `read` with regex |
| 4 | `# POS:` header text | L110 `sed -n '/^# POS: /{…;q}'` | F (1 proc/tool) | read up to first `# POS:` line in bash loop |
| 5 | em-dash presence | L115 `grep -q ' — ' <<<"$headline"` | F (1 proc/tool, heredoc string) | `[[ $headline == *' — '* ]]` |
| 6 | `# POS:` line number | L118 `grep -nE … \| head -1 \| cut -d: -f1` | F (3 procs/tool) | captured in the same loop as #4 |
| 7 | deps-guard-before-help | L127 `first_guard_line` | F (0 extra — bash loop) | already bash-native |
| 8 | help-line number | L128 `first_line``grep -nE … \| while read` | F (1 proc/tool) | same first-match read loop as #4/#6 |
| 9 | top-level `local` scan | L137157 with **L143 `printf '%s\n' "$line" \| sed -nE … \| tail -1`** | **L (3 procs per line of every tool)** | `[[ $line =~ <<-?[[:space:]]*([A-Za-z0-9_]+) ]]` in-bash |
| 10 | stdin-reader detection | L5980 `uses_stdin` with **L67 `printf \| sed \| tail`** | **L (3 procs per line — scans every tool a 2nd time)** | same `[[ =~ ]]` regex; can also merge with #9 into ONE pass |
| 11 | POS.md reference | L169 `grep -q "$(basename "$f")" DOC/POS.md` | F (1 proc/tool) | read POS.md into a var once; `[[ $posmd == *$basename* ]]` |
| 12 | INTERACTIVE_CMDS entry check | L174180 | — | fine |
| 13 | plugin common.sh / POS_PLUGIN / app uninstall fn+case / systemd / wrapper checks | L184,187,196,197,201,208,211,218,224 `has_regex` | F (12 procs/file each) | single-read first-match loop per file |
| 14 | wrapper line count | L221222 `wc -l < "$f"` **twice** | F (2 procs/wrapper) | `mapfile -t lines < "$f"; ${#lines[@]}` |
| 15 | secret-literal scan | L229243 `grep -nE` per file + **L235 `grep -qE … <<<"$body"` per matched line** | F + L (heredoc-string greps) | `[[ $body =~ (TOKEN|PASSWORD|…)= ]]` |
| 16 | system-path write scan | L245258 `grep -nE` per file + **L251 two `grep -qE <<<"$body"` per matched line** | F + L | `[[ $body =~ (>|>>|tee ) ]] && [[ $body =~ (/etc/|\$HOME|/usr/local) ]]` |
| 17 | `last_line()` | L3942 | **dead code — defined, never called** | delete |
### 1.3 Estimated cost
- The two per-line scans (#9, #10) each read every line of every `bin/pos-*` tool twice. The repo has 45 tools with several >1000-line files (pos-docker-vbox 1125, pos-media-ytsync 1213, pos-network-download 1108) — total tool lines ≈ 1520k. At 3 forks/line × 2 scans ≈ **90k120k `printf|sed|tail` subprocess forks per lint run** just from those two rules.
- Remaining rules add ≈ 1015 forks per tool ≈ 500700 more forks total. The secret/system-path scans add one grep per file plus per-matched-line heredoc greps.
- Expected effect: the lint time is dominated by process creation (fork/exec), not by grep itself. Replacing #9/#10 with `[[ =~ ]]` and merging into one pass should cut lint wall time by the largest factor; the heredoc-string greps (#5, #15, #16) are cheap per call but numerous.
### 1.4 `scripts/check-sync.sh` (42 lines) — brief
- `bash -n` per file, exec-bit loop, doc-sync via `gen-docs.sh --check`, 3 dispatch smokes. Per-file spawns are inherent to `bash -n` (must run bash anyway); no obvious perf bug.
- Correctness note: L1416 glob list misses `features/*.sh`? — actually it includes `features/*.sh` (line 15 `features/*.sh`). It does NOT include `completions/*` other than `completions/pos.bash` (fine) and does not `bash -n` `install.sh`'s sourced libs beyond the list — libs are covered. No hotspot.
### 1.5 `scripts/gen-docs.sh` (254 lines) — brief
- `sed` per header per tool (L4247: 6 sed calls/tool) — minor; only runs on `make gen`, not per commit.
- L207210 check mode: `sed` block extract + `cat` + `diff` per block — fine for check.
- Correctness hotspot: docmap convergence loop (L244252) re-runs `regen_block docmap` up to 5 times by design; each iteration re-does a full-file `sed` + `grep -n` + `wc -l` — acceptable (documented convergence), but on a 700-line file it is the single slowest part of gen; an in-memory line accounting would converge in one pass. Not a bug.
### Task 1 — Ranked change list (no implementation)
1. Merge the per-line `printf|sed|tail` delimiter extraction into a single bash-native pass using `[[ $line =~ <<-?[[:space:]]*([A-Za-z0-9_]+) ]]` — used by the top-level-`local` scan (L143) and `uses_stdin` (L67). Highest ROI; removes the ~90120k fork estimate.
2. Replace per-line heredoc-string `grep -q <<<"$body"` with `[[ $body =~ … ]]` in the secret (L235) and system-path (L251) scans.
3. Replace per-file `grep`/`sed|head|cut`/`wc` with a single read of the first ~6 lines per tool (covers L92/95/110/115/118/128) plus `mapfile` line counts for wrappers (L221).
4. Replace `grep -q <basename> DOC/POS.md` (L169) with one preloaded POS.md content check.
5. Delete the dead `last_line()` (L3942).
---
## Task 2 — Install/uninstall symmetry
### 2.1 What exists
- **Installer:** `install.sh` phases 14 + optional apps. Uninstall path documented in `install.sh:6871` (apps only) and provided as a **CLI tool** `bin/pos-system-uninstall` (not a `make uninstall`, not a scripts/ uninstaller — grep of `Makefile`, `scripts/`, `README.md` shows no `make uninstall`; `apps/install.sh --uninstall` handles optional desktop apps only).
- **Uninstaller:** `bin/pos-system-uninstall` — Tier 1 (always): binaries, plugins, known systemd services, shell integration; Tier 2 (`--config`): `~/.config/linux_post_install`; Tier 3 (`--data`): `~/.local/share/linux_post_install`.
### 2.2 Install inventory vs uninstall coverage
| Artifact | Installed by | Removal path | Verdict |
|---|---|---|---|
| `bin/*``/usr/local/bin/` (45 pos-*, pos, flag-*, wr-*, mp3/mp4/vbox/ssh-load-all) | Phase 2 (install.sh:136140) | Tier 1: `/usr/local/bin/pos` + `compgen -G /usr/local/bin/pos-*` + legacy names (L5383, 229257) | **SYMMETRIC** |
| `lib/common.sh`, `lib/menu-lib.sh`, `lib/share-lib.sh``/usr/local/bin/` | Phase 2 (install.sh:143144) | Tier 1 (pos-system-uninstall:6264) | **SYMMETRIC** |
| `lib/{flags,notify,entertainment-lib,scheduler-lib,config-ui,user-timers-lib,entertainment-plugin-lib,usb-lib,registry}.sh``/usr/local/bin/` | Phase 2 (install.sh:143144) | **none** | **INSTALL-ONLY** (9 of 12 libs) |
| `lib/ai-providers/*.sh``/usr/local/bin/ai-providers/` | Phase 2 (install.sh:154161) | Tier 1 `rm -rf /usr/local/bin/ai-providers` (L244) | **SYMMETRIC** |
| `entertainment/*.sh``/usr/local/bin/` | Phase 2 (install.sh:167173) | Tier 1 — **hardcoded list** `weather.sh gold.sh joke.sh` (L72, 248) | **SYMMETRIC today**; breaks automatically if a 4th plugin is added |
| `x64_bin|arm64_bin/*``/usr/local/bin/` | Phase 2 (install.sh:179194) | Tier 1 hardcoded `wihotspot wihotspot-gui create_ap` (L86, 260) | **SYMMETRIC today**; same hardcode fragility |
| `features/*``/usr/local/bin/` (--feature) | Phase 2 (install.sh:197222) + flag set | Tier 1 `autostart.sh usb-automount.sh` (L91, 265) | **SYMMETRIC today**; hardcoded |
| **Feature-flag store** `/usr/local/share/linux_post_install/flags/` | Phase 2 `flag_set` (install.sh:216) | **none** (no `flags`/`flag` match in pos-system-uninstall) | **INSTALL-ONLY** |
| **ScaleTail clone** `/usr/local/share/linux_post_install/scale-tail` | Phase 4 (install.sh:237243) | **none** (only bash-completion under /usr/local/share is removed, L108) | **INSTALL-ONLY** |
| `completions/pos.bash``/usr/local/share/bash-completion/completions/pos.bash` | postinstall.sh:100107 | Tier 1 (L108, 284) | **SYMMETRIC** |
| systemd `*.service`/`*.timer``/etc/systemd/system/` + enable | postinstall.sh:139166 | Tier 1: 3 known + find `-name '*linux_post_install*' -o -name 'pos-*'` (L287311) | **SYMMETRIC** (system units) |
| `config/authorized_keys``~/.ssh/authorized_keys` | postinstall.sh:110137 | **none** (tier 2 only targets `~/.config/linux_post_install`) | **INSTALL-ONLY** (by design — user data) |
| `config/rclone.conf``~/.config/rclone/rclone.conf` | postinstall.sh:1017 | **none** (tier 2 path is `linux_post_install` only) | **INSTALL-ONLY** |
| `config/{entertainment,system,notify,ai}.env``~/.config/linux_post_install/` | postinstall.sh:2250 | Tier 2 (`--config`) find over the dir (L147155) | **SYMMETRIC** (opt-in tier) |
| `config/schedule.d/*.env``~/.config/linux_post_install/schedule.d/` | postinstall.sh:5777 | Tier 2 + rmdir schedule.d (L351355) | **SYMMETRIC** (opt-in tier) |
| PATH line + completion line in `~/.bashrc` | postinstall.sh:8098 | Tier 1 sed removals (L320322) | **SYMMETRIC** |
| apt packages (25+) + yt-dlp → `/usr/local/bin/yt-dlp` + cpufreq | preinstall.sh:2875 | **none** (uninstaller never touches apt or yt-dlp) | **INSTALL-ONLY** (likely deliberate — system packages) |
### 2.3 Runtime-created state (created by tools at runtime, not install.sh)
| Artifact | Created by | Uninstall path in pos-system-uninstall | Verdict |
|---|---|---|---|
| `~/.config/linux_post_install/<tool>.env` (ai, telegram, matrix, scrcpy, download, ytsync, grab, ai-aliases, compose) | tools' config writes | Tier 2 (`--config`) | **RUNTIME-STATE / SYMMETRIC** (removed with --config) |
| `~/.local/share/linux_post_install/{logs,ytsync,ai/models,entertainment/last,backups}` | bin/pos logging + tools | Tier 3 (`--data`) | **RUNTIME-STATE / SYMMETRIC** (removed with --data) |
| **systemd *user* units** `~/.config/systemd/user/`: `pos-aria2.service`+`pos-aria2-retry.{service,timer}` (pos-network-download:172190,633669), telegram-listener unit (pos-communication-telegram-listener:471521), matrix-listener unit (pos-communication-matrix-listener:315364), `pos-ai-server.service` (pos-ai-server:500522), entertainment timers `pos-entertainment-*.timer` (user-timers-lib), scheduler per-job timers (scheduler-lib) | runtime tool subcommands | **none** — Tier 1 only scans `/etc/systemd/system` (L287311); Tier 2 only `~/.config/linux_post_install`; `~/.config/systemd/user/` is outside both | **INSTALL-ONLY** (from the uninstaller's perspective; each tool's own `stop`/`disable` subcommand does remove its own unit, e.g. `pos network download stop` L209211) |
| `~/.local/bin/pos-ai-hook.sh` + `ai-aliases.sh` wrappers | pos-ai-alias | Tier 1 pos-ai-hook + marker-managed alias scan (L96105, 270281) | **SYMMETRIC** |
| `~/.config/rclone/rclone.conf` (from postinstall) | postinstall.sh:1017 | none | **INSTALL-ONLY** |
### Task 2 — Ranked change list (no implementation)
1. **Remove the 9 orphaned libs** (`flags.sh`, `notify.sh`, `entertainment-lib.sh`, `scheduler-lib.sh`, `config-ui.sh`, `user-timers-lib.sh`, `entertainment-plugin-lib.sh`, `usb-lib.sh`, `registry.sh`) in Tier 1 — the biggest INSTALL-ONLY gap (hardcoded `common.sh menu-lib.sh share-lib.sh` only, pos-system-uninstall:6264).
2. **Remove ScaleTail templates** `/usr/local/share/linux_post_install/scale-tail` and the **feature-flag store** `/usr/local/share/linux_post_install/flags/` in Tier 1 (documented install outputs in AGENT_Context §3/§10, no removal).
3. **Add a user-unit sweep** to Tier 1: disable+remove matching units in `~/.config/systemd/user/` (prefixes `pos-*`, `pos-entertainment-*`, `pos-schedule-*` etc.), or document that per-tool `stop` is the supported path.
4. De-hardcode the entertainment-plugin / prebuilt-binary / feature names in the uninstaller to directory-driven discovery (mirror install.sh's loops) so future plugins/bins don't silently become INSTALL-ONLY.
5. Decide (and document) whether `~/.config/rclone`, `~/.ssh/authorized_keys` additions, apt packages, and `/usr/local/bin/yt-dlp` are intentionally outside uninstall — currently silent.
---
## Task 3 — Config-parsing duplication
### 3.1 Inventory
**Shared loaders that exist:**
| Loader | Location | Used by |
|---|---|---|
| `load_system_env()` (env-file → export, env-wins) | lib/common.sh:146159 | pos-system-health, pos-system-backup, pos-media-sync (system.env) |
| `cfg_value()` / `cfg_write()` (key read/write, `KEY="value"`, chmod 600) | lib/config-ui.sh:311344 | pos-config; pos-entertainment-config:L98 sources config-ui dynamically; config-scope registry consumers |
| `config_value()` / `write_config_key()` (key read/write, same semantics) | lib/entertainment-lib.sh:2854 | pos-entertainment-{config,status,enable,disable,send} |
| inline `grep '^NOTIFY_PLATFORM=' \| tail -1 \| cut` | lib/notify.sh:41 | notify_send |
**Hand-rolled near-identical `load_config()`-style loaders (9)** — each is the same ~14-line loop: `grep -E '^[A-Z_]+=' | while IFS='=' read k v` + quote-strip + `[ -z "${!k:-}" ] && export`:
1. bin/pos-communication-telegram-sender:6174 (`load_config`)
2. bin/pos-communication-matrix-listener:6577 (`load_config`)
3. bin/pos-communication-telegram-listener:8698 (`load_config`)
4. bin/pos-communication-matrix-sender:4457 (`load_config`)
5. bin/pos-communication-scrcpy:1428 (`load_config`)
6. bin/pos-ai:130160 (`load_config`, plus legacy-file loop)
7. bin/pos-ai-server:2135 (`load_config`)
8. bin/pos-ai-hf:3044 (`load_hf_config`)
9. bin/pos-media-grab:1023 (`load_grab_config`)
**Bespoke parsers (4+):**
- bin/pos-network-download:3237 `load_secret` — single-key `grep'^RPC_SECRET=' | head -1 | cut -d= -f2-`; also duplicated inline at L153154
- bin/pos-docker-compose:910 + layered strategy (`template < global compose.env < per-service .env`, documented L3148) — reads global config via `CONFIG_ENV` and per-service envs
- bin/pos-share-smb-server:97 `reload_config` — Samba-specific
- bin/pos-media-ytsync:2830 `_YTSYNC_CFG` via `pos config ytsync` scope; plus the share-client (`pos-share-smb-client`) creds records parsing
**Counts:** 45 `pos-*` tools; ~20 source `lib/common.sh`; **9 hand-roll their own file parser**; only `pos-config` and the entertainment tools use a shared key-value loader; 3 use `load_system_env`; the 5 self-contained communication tools duplicate the loader because they don't source common.sh (documented convention: guarded inline fallback copies in DEV.md).
### 3.2 Consistency findings
- **Precedence order** is `env > config-file > defaults` everywhere the hand-rolled loaders are used (`if [ -z "${!k:-}" ]` before export; defaults applied later via `${VAR:-default}`). CLI-vs-config precedence is declared `CLI > environment > config file` in the three tools that document it (telegram-sender:45, scrcpy:70, matrix-sender:29). pos-docker-compose is the outlier model (per-service file wins over global file; no env) — a different domain, but also the only tool where "config file" beats "global defaults" deliberately.
- **CRLF handling diverges:** 5 loaders strip `\r` (matrix-sender:53, scrcpy:23, ai:139, ai-server:30, ai-hf:39) but 5 do NOT (telegram-sender, matrix-listener, telegram-listener, media-grab, and `load_system_env` in common.sh:154). A Windows-edited `.env` parses differently depending on which tool reads it.
- **CONFIG_DIR / XDG seam divergence:** self-contained tools (+ config-ui.sh:32, notify.sh:27) carry the guarded `CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"` copy; but several tools that **source** common.sh (which defines `CONFIG_DIR` at line 19) still hardcode `$HOME/.config/linux_post_install/...`: pos-ai:11, pos-ai-hf:26, pos-ai-server:15, pos-media-grab:11, common.sh load_system_env:147, entertainment-plugin-lib:15, pos-docker-compose:10. So `CONFIG_DIR`/`XDG_CONFIG_HOME` overrides work for some tools and are silently ignored by others.
- **Key-value writers duplicated** — `cfg_write` (config-ui.sh:323) and `write_config_key` (entertainment-lib.sh:36) are the same algorithm (grep -v + append, `-` deletes, chmod 600); only the value-quoting and the multi-line warning differ.
### 3.3 Recommendation (no implementation)
- **Owner: `lib/config-ui.sh`.** It already hosts the `POS_CONFIG` scope registry that `pos config` consumes, has secret masking/validation helpers, and is installed to `/usr/local/bin` alongside the tools.
- Add one generic loader there, e.g. `load_env_file <file>` (env-wins export loop with quote + CRLF strip parsed consistently) and have `load_system_env` delegate to it.
- Merge `entertainment-lib.sh` `config_value`/`write_config_key` into `cfg_value`/`cfg_write` (keep `config_value` as a thin alias for the entertainment tools, or migrate the 5 call sites).
- **Migration targets:** the 9 hand-rolled loaders → `load_env_file` (source `config-ui.sh` in the 5 self-contained communication tools, replacing their guarded inline copies and CONFIG_DIR blocks); `pos-network-download``cfg_value "$CONFIG_FILE" RPC_SECRET`; `pos-docker-compose` `config show``cfg_value`/`cfg_write` for the global config.
- Unify CRLF-strip and the `CONFIG_DIR` path source across every loader during the migration.
### Task 3 — Ranked change list (no implementation)
1. Add `load_env_file` to `lib/config-ui.sh`; make `common.sh load_system_env` delegate; fix the CRLF split in the process.
2. Migrate the 9 hand-rolled loaders (list in 3.1) to it; make the 5 self-contained communication tools source `config-ui.sh` instead of the inline CONFIG_DIR+load_config copies.
3. Fold `entertainment-lib.sh` read/write helpers into `cfg_value`/`cfg_write` (alias or migrate the 5 entertainment call sites).
4. Route `pos-network-download` `load_secret` and `pos-docker-compose` global-config reads through `cfg_value`.
5. Replace hardcoded `$HOME/.config/linux_post_install/...` in the common.sh-sourcing tools with the sourced `CONFIG_DIR` (pos-ai, pos-ai-hf, pos-ai-server, pos-media-grab, load_system_env, entertainment-plugin-lib, pos-docker-compose).
---
## Task 4 — Shell-correctness hotspots (high-confidence only)
Method: targeted scan of `bin/` and `lib/` for unquoted `$var` in args/array appends, `for x in $list`, `[ $x = … ]`, `rm -rf $VAR`, unguarded `cd`, missing `|| true` in pipelines under `set -euo pipefail`, `eval` of derived strings. Only high-confidence items below.
### 4.1 High-confidence findings
- **H-001 (WARN) — `bin/pos-ai:388,403` `run eval "$cmd"`.** `_prompt_run_command` executes a command string extracted from AI output. Interactive path prompts on `/dev/tty`; the `--trust` path (L385388) auto-executes without confirmation. Deliberate feature, but any AI-output-derived command executed through `eval` is a shell-injection-relevant surface — recommend keeping, but it deserves explicit review of what `trusted=1` callers feed it. Classification: FACT (code), design concern.
- **H-002 (WARN) — `bin/pos-system-uninstall:333` `sed -i '/pos/d' "$HOME/.bash_completion"`.** Deletes **every** line containing the substring `pos` from a user-owned file, not just pos-managed lines (unlike the `pos-ai-hook` marker check at L277). A line like `complete -F _git checkout` is safe, but any unrelated completion containing "pos" (e.g. `repos`, `compose-help`, `dispose`) is silently removed — and this runs in default Tier 1. Classification: FACT.
- **H-003 (WARN) — `bin/pos-system-uninstall:320322` `sed -i` on `~/.bashrc`.** Removal of PATH/completion/hook lines is line-based and unanchored at line start (`/source.*pos\.bash/d`, `/linux_post_install.*PATH/d`, `/source.*pos-ai-hook/d`); a user comment mentioning `pos.bash` is deleted. Lower risk than H-002 but same class. Classification: FACT.
- **H-004 (WARN) — `scripts/lint-conventions.sh:31,41,67,84,110,118,143,235,251`.** Under `set -euo pipefail`, the `grep | while read` and `... | tail -1 | cut` pipelines are only safe because of the `|| true` / `2>/dev/null` guards and the non-final elements' exit codes. The per-line `printf | sed | tail -1` inside the read loop (L67/L143) is the perf hotspot from Task 1 AND a correctness risk: if `sed` ever exits non-zero for a given line under `pipefail`, the surrounding `while read` loop aborts mid-scan. Classification: FACT (perf measured as static analysis); correctness risk is conditional, not observed.
### 4.2 Checked and cleared (not bugs)
- `rm -rf`/`rm -f` in `bin/` are consistently quoted (`pos-ai-hf:644,698,835,989`; `pos-system-uninstall` all lines; `pos-docker-vbox:1115`; app scripts). No unquoted/empty `rm -rf $VAR` found.
- Unquoted `[ $x … ]` comparisons: none found in `bin/` (only `"$var"` forms).
- `for x in $list` sites (`pos-docker-health:38`, `pos-docker-ps:36`, `pos-tree:71`, `pos-system-health:170`, `pos-network-checkport:408`, `pos-entertainment-status:39`, `pos:123`) intentionally word-split newline/comma-separated IDs or sorted output with no spaces in elements — not bugs at present, but a space in a future element (e.g. a plugin filename) would silently split. Low-priority hardening, not a defect.
- `cd` sites are guarded (`pos-docker-vbox:1029,1040` use `(cd "$d" 2>/dev/null && pwd) || …`; `pos-docker-compose:221260` wrap in subshells with `set -e` context).
- `pos-share-smb-client:457` `sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")"` — properly quoted.
- `find /etc/systemd/system/ -name '*linux_post_install*' -o -name 'pos-*'` (pos-system-uninstall:311) — `-o` binds both predicates to the stated path; matches both patterns as intended. Not a bug.
- `bin/pos` logging tee pipes: INTERACTIVE_CMDS handling verified by lint rule and existing registrations — no new finding.
### Task 4 — Ranked change list (no implementation)
1. Restrict `pos-system-uninstall` `.bash_completion`/`.bashrc` removal to anchored, marker-based patterns (e.g. only lines the installer itself added, or apply the `grep -q 'Managed by pos…'`-style marker check used for alias wrappers).
2. Review `bin/pos-ai` `_prompt_run_command` trust boundaries: confirm every `trusted=1` caller is user-flagged and document the eval surface (or re-run through `bash -c` with validation).
3. Convert lint L67/L143 per-line `printf|sed|tail` to `[[ =~ ]]` (also removes the pipefail-mid-loop abort risk).
4. Optional hardening: quote the `for x in $list` sites that consume plugin names/scheduled-job names where elements could contain spaces.
---
## Uncertainties
- **Lint wall time** could not be measured (sandbox denies `time`, `make`, `bash`). Estimates are derived from hotspot counts and file sizes (45 tools, 1520k total lines); real numbers should be captured by a runner-capable agent (`make lint` ×2 + bare script) — see Handoff.
- The exact fork count per run is an INFERENCE (each `printf|sed|tail` is at least 3 forks; actual exec cost depends on PATH lookup and filesystem state).
- Whether apt packages / yt-dlp / `~/.ssh/authorized_keys` / `~/.config/rclone` are *supposed* to survive uninstall is a product decision, not verifiable from code.
- Whether the runtime-created user units are "expected to persist" is not documented anywhere in the repo; the uninstaller help text ("services") implies coverage, which is not delivered.
## Important Files
- `scripts/lint-conventions.sh` — all Task 1 hotspots (L31,41,67,84,92,95,110,115,118,137157,162,169,221222,235,251; dead `last_line` L3942)
- `scripts/check-sync.sh`, `scripts/gen-docs.sh` — gates; convergence loop L244252
- `install.sh` — phases, `should_run` (L100117), copy targets (L136222), ScaleTail (L237243)
- `preinstall.sh` — apt PACKAGES (L2843), yt-dlp (L6568) — no uninstall counterpart
- `postinstall.sh` — rclone/entertainment/system/notify/ai env templates, schedule.d, .bashrc, completion, systemd
- `bin/pos-system-uninstall` — tiers, lib list L6264, user-unit gap, H-002/H-003, find L311
- `bin/pos`, `bin/pos-ai`, `bin/pos-communication-{telegram,matrix}-{sender,listener}`, `bin/pos-communication-scrcpy`, `bin/pos-ai-server`, `bin/pos-ai-hf`, `bin/pos-media-grab`, `bin/pos-network-download`, `bin/pos-docker-compose`, `bin/pos-share-smb-server` — config-loading inventory (Task 3)
- `lib/common.sh` (load_system_env), `lib/config-ui.sh` (cfg_value/cfg_write), `lib/entertainment-lib.sh` (config_value/write_config_key), `lib/notify.sh` — loader candidates
- `DOC/DEV.md:182213` — env-seam rules the loader centralization should preserve
## Handoff
- **Status:** OBJECTIVE_SATISFIED (plus measurement note)
- **Objective:** evidence audit of lint performance, install/uninstall symmetry, config-parsing duplication, shell-correctness hotspots — completed read-only.
- **Evidence / completed work:** this report; hotspot inventory with file:line; install/uninstall matrix; 9-loader duplication census with CRLF and CONFIG_DIR inconsistencies; 3 high-confidence shell hotspots.
- **Affected areas:** `scripts/lint-conventions.sh`, `bin/pos-system-uninstall`, `lib/config-ui.sh` + `lib/entertainment-lib.sh` + `lib/common.sh` (loader centralization), 9 tool files, `bin/pos-ai`.
- **Scope/decision boundary:** no code changed. Centralizing the loader (Task 3) is a deliberate cross-tool refactor with a doc convention ("guarded inline fallback copies" in DEV.md) — that is an Architect/Designer decision boundary, not a mechanical fix.
- **Verification performed:** full reads of lint/check-sync/gen-docs/install/preinstall/postinstall/uninstall/common/config-ui; greps across `bin/`+`lib/` for loaders, rm/cd/eval/for-splitting patterns; shared-memory check of maintainer/architect reports (no overlap: the 2026-09-06 convention-sweep was about POS header/doc drift, not these four areas).
- **Remaining uncertainty:** measured lint wall time (needs a runner-capable agent); intended persistence of apt packages/rclone/user units; actual fork count (inference).
- **Recommended next agent:** **Architect** (for the loader centralization decision: which library owns `load_env_file`, how self-contained tools source config-ui.sh without breaking the "no shared lib? inline fallbacks" convention) — and/or **Maintainer** for the uninstaller gaps + lint per-line hotspot rewrite if a decision is not needed.
- **Reason:** Task 3's fix crosses the documented DEV.md convention and 9 tool files (architectural boundary); Tasks 1/2/4 are mechanical cleanups that a Maintainer can implement once the loader decision is made.