Closes #18 All six Command::new call sites audited: - bitwarden.rs (sh -c): shell string is a hardcoded literal; no user data. - bitwarden.rs (bw unlock): all args hardcoded. - bitwarden.rs (bw get): item_name and session passed as discrete .args() elements; no shell. - nothooks/runner.rs (nu/sh/etc): interp and script path passed as discrete args; no shell. - notstrap/repo.rs (git clone): url and dest passed as discrete .arg() calls; no shell. - notfiles/tests/integration.rs: test harness with hardcoded args only. Added SAFETY comments on the three sites that handle user-controlled config values.
nothooks
Nushell hook runner for the notfiles workspace. Executes .nu scripts in two
distinct lifecycle phases — dot (always) and setup (once) — with
state persistence so setup hooks are not re-run across invocations.
Concepts
HookPhase
Each hook belongs to exactly one phase:
| Phase | When it runs | Typical use |
|---|---|---|
dot |
Every time nothooks is invoked | Apply shell config, set env vars, refresh symlinks |
setup |
Once per machine (tracked in state) | Install packages, create dirs, first-time configuration |
HookSpec
A hook is described by three fields (defined in notcore):
[[hooks]]
name = "install-starship"
script = "hooks/setup/install-starship.nu"
phase = "setup"
[[hooks]]
name = "set-default-shell"
script = "hooks/dot/shell.nu"
phase = "dot"
State tracking
Setup hooks are tracked in .nothooks-state.toml in the state directory
(typically the dotfiles root). Once a setup hook completes successfully its
name is recorded and it will be skipped on all future runs.
# .nothooks-state.toml (auto-generated, do not edit manually)
completed_setup_hooks = ["install-starship", "configure-git"]
Dot hooks are never recorded in state — they run unconditionally on every invocation.
--force re-run
To re-run setup hooks (e.g. after a machine rebuild or to apply changes),
construct the runner with HookRunner::with_force. This bypasses the state
check so all setup hooks execute regardless of prior completion.
The notstrap CLI exposes this as --force.
Hook script lifecycle
notstrap / nothooks invoked
│
├── dot phase
│ └── for each hook where phase == "dot"
│ └── nu <script> # always runs
│
└── setup phase
└── for each hook where phase == "setup"
├── state.is_done(name)?
│ ├── yes (and !force) → skip
│ └── no (or force) → nu <script>
│ └── success → state.mark_done(name)
└── report collected
Writing a hook script
Hook scripts are plain Nushell (.nu) files. They receive no arguments and
communicate success/failure via exit code (0 = success, non-zero = failure).
#!/usr/bin/env nu
# hooks/setup/install-starship.nu
# Installs starship prompt if not already present.
if (which starship | is-empty) {
print "Installing starship..."
sh -c "curl -sS https://starship.rs/install.sh | sh -s -- -y"
} else {
print "starship already installed, skipping"
}
#!/usr/bin/env nu
# hooks/dot/xdg-dirs.nu
# Ensure XDG base directories exist on every login.
mkdir ~/.config ~/.cache ~/.local/share ~/.local/state
Usage from Rust
use nothooks::{HookRunner, run_phase};
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
let hooks: Vec<HookSpec> = /* load from notfiles.toml */;
let state_dir = PathBuf::from("/path/to/dotfiles");
// Normal run
let runner = HookRunner::new(state_dir.clone());
// Force re-run of setup hooks
let runner = HookRunner::with_force(state_dir.clone());
let dot_report = run_phase(&hooks, &HookPhase::Dot, &runner);
let setup_report = run_phase(&hooks, &HookPhase::Setup, &runner);
run_phase returns a notcore::Report containing a StepStatus (Ok,
Skipped, or Failed(msg)) for each hook in that phase.