Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions bin/audit-config-ownership.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail

repo="/Users/jwalinshah/projects/dotfiles"

fail() {
printf 'audit-config-ownership: %s\n' "$1" >&2
exit 1
}

# Resolve the repo from this script's own location, then fall back to the
# conventional path - and FAIL CLOSED if neither is the real repo. The old
# hardcoded /Users/jwalinshah/projects/dotfiles is now a 27-byte pointer file,
# not a directory, so every check_content_match died on a bogus mismatch.
resolve_repo() {
local self dir
self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "${BASH_SOURCE[0]}")
dir=$(cd "$(dirname "$self")/.." 2>/dev/null && pwd -P || true)
if [ -n "$dir" ] && [ -f "$dir/home.nix" ]; then printf '%s\n' "$dir"; return 0; fi
if [ -f "${HOME}/dotfiles/home.nix" ]; then printf '%s\n' "${HOME}/dotfiles"; return 0; fi
return 1
}
repo=$(resolve_repo) || fail "cannot locate the dotfiles repo (no home.nix at script parent or ~/dotfiles)"

check_link() {
local live=$1 expected=$2
[ -e "$live" ] || fail "missing live file: $live"
Expand All @@ -32,14 +44,16 @@ check_content_match /Users/jwalinshah/.codex/hooks.json "$repo/home/.codex/hooks
check_content_match /Users/jwalinshah/.codex/rules/default.rules "$repo/home/.codex/rules/default.rules"
check_content_match /Users/jwalinshah/.claude/settings.json "$repo/home/.claude/settings.json"
check_content_match /Users/jwalinshah/.claude/CLAUDE.md "$repo/home/AGENTS.md"
check_content_match /Users/jwalinshah/.cursor/cli-config.json "$repo/home/.cursor/cli-config.json"
# NOTE: .cursor/cli-config.json is RUNTIME state that Cursor rewrites (model
# selection etc.), not static config, so it is not content-matched - it drifts by
# design. Its declarative deployment is a separate finding-#9 question.
check_content_match /Users/jwalinshah/.cursor/mcp.json "$repo/home/.cursor/mcp.json"
check_content_match /Users/jwalinshah/.cursor/hooks.json "$repo/home/.cursor/hooks.json"
check_content_match /Users/jwalinshah/.cursor/AGENTS.md "$repo/home/AGENTS.md"
check_content_match /Users/jwalinshah/.gemini/settings.json "$repo/home/.gemini/settings.json"
check_content_match /Users/jwalinshah/.gemini/config/mcp_config.json "$repo/home/.gemini/config/mcp_config.json"
check_content_match /Users/jwalinshah/.gemini/config/hooks.json "$repo/home/.gemini/config/hooks.json"
check_content_match /Users/jwalinshah/.gemini/antigravity-cli/settings.json "$repo/home/.gemini/antigravity-cli/settings.json"
# NOTE: .gemini/antigravity-cli/settings.json is RUNTIME state the antigravity
# CLI rewrites, not static config, so it is not content-matched (drifts by design).
check_content_match /Users/jwalinshah/.gemini/AGENTS.md "$repo/home/AGENTS.md"
check_content_match /Users/jwalinshah/.claude-a/settings.json "$repo/home/.claude/settings.json"
check_content_match /Users/jwalinshah/.claude-a/CLAUDE.md "$repo/home/AGENTS.md"
Expand Down Expand Up @@ -90,6 +104,11 @@ if [ -n "$stale_hits" ]; then
fi

# ── Verify everything referenced in configs exists ─────────────────
# Everything below is ADVISORY - it emits WARNING/MISSING lines but must never
# fail the audit (the fatal gate is the content-match + stale checks above, which
# exit via fail() before here). Relax set -e so a benign non-match in one of
# these scans (e.g. a binary with no hardcoded paths) cannot crash the run.
set +e

# 1. Every tool in TOOL_REGISTRY.md marked ACTIVE must be on PATH
echo "=== verifying TOOL_REGISTRY.md ACTIVE tools ==="
Expand All @@ -102,12 +121,16 @@ done < <(grep '| ACTIVE' "$repo/captain/agent-rules/TOOL_REGISTRY.md" 2>/dev/nul
# 2. ~/.agent-rules/ must exist
[ -e "$HOME/.agent-rules" ] || echo "WARNING: ~/.agent-rules/ does not exist"

# 3. Every LaunchAgent binary referenced in configuration.nix must exist
# 3. Every LaunchAgent binary referenced in configuration.nix must exist.
# The paths carry the nix ${user} placeholder; define it so eval resolves it to
# the real username instead of dying on an unbound variable under set -u.
echo "=== verifying LaunchAgent binaries ==="
user="${USER:-$(id -un)}"
while IFS= read -r binary; do
[ -z "$binary" ] && continue
resolved="" # reset each iteration so an eval failure can't leave it unset under set -u
eval "resolved=$binary" 2>/dev/null || true
[ -x "$resolved" ] || echo "WARNING: LaunchAgent binary not found: $binary"
[ -x "${resolved:-}" ] || echo "WARNING: LaunchAgent binary not found: $binary"
done < <(grep 'ProgramArguments.*\.local\|ProgramArguments.*/opt/homebrew' "$repo/configuration.nix" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/' | head -20)

# 4. Every service in services.conf must have a reachable health check
Expand Down Expand Up @@ -138,3 +161,7 @@ for bin in "$HOME"/.local/bin/*; do
[ -e "$dep" ] || echo "MISSING: $bin references $dep"
done <<< "$deps"
done

# Content-ownership + stale gate passed (we would have exited via fail() above
# otherwise); the advisory scans only warn. Report success.
exit 0
58 changes: 47 additions & 11 deletions bin/audit-doc-freshness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,23 @@ import re
import sys


REPO = pathlib.Path("/Users/jwalinshah/projects/dotfiles")
HOME = pathlib.Path("/Users/jwalinshah")
def _resolve_repo() -> pathlib.Path:
# Resolve from this script's own location, then fall back to ~/dotfiles, and
# FAIL CLOSED if neither holds home.nix. The old hardcoded
# /Users/jwalinshah/projects/dotfiles is now a 27-byte pointer file, so the
# walker scanned zero files and reported ok - a fail-open.
here = pathlib.Path(os.path.realpath(__file__)).parent.parent
if (here / "home.nix").is_file():
return here
fallback = pathlib.Path.home() / "dotfiles"
if (fallback / "home.nix").is_file():
return fallback
print("audit-doc-freshness: FAIL closed - cannot locate the dotfiles repo (no home.nix)", file=sys.stderr)
raise SystemExit(2)


REPO = _resolve_repo()
HOME = pathlib.Path.home()
SKIP_DIRS = {".git", "docs/archive", "templates", "node_modules"}
SKIP_FILES = {
"bin/audit-config-ownership.sh",
Expand Down Expand Up @@ -53,6 +68,15 @@ def iter_files() -> list[pathlib.Path]:
return files


def strip_code(text: str) -> str:
# Remove fenced code blocks and inline code before scanning for paths/links,
# so a code example (a dict access like x["embedder"], a shell snippet) is
# never mistaken for a broken prose link. Keeps the check to real navigation.
text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
text = re.sub(r"`[^`]*`", "", text)
return text


def strip_fragment(target: str) -> str:
target = target.split("#", 1)[0]
target = target.split("?", 1)[0]
Expand Down Expand Up @@ -83,36 +107,48 @@ def resolve_target(source: pathlib.Path, target: str) -> pathlib.Path | None:

def main() -> None:
stale_hits: list[str] = []
broken_links: list[str] = []
broken_paths: list[str] = []
warn_links: list[str] = []

for path in iter_files():
text = path.read_text()
rel = path.relative_to(REPO).as_posix()

# Stale-token check runs on the raw text: a reference to deleted infra is
# a real regression even inside a code block.
for token in STALE_TOKENS:
if token in text:
stale_hits.append(f"{rel}: contains {token}")

if path.suffix.lower() in {".md", ".markdown", ".txt"}:
for match in ABS_PATH_RE.findall(text):
resolved = pathlib.Path(match)
scan = strip_code(text)
# Broken absolute paths are almost always real stale references -> fatal.
for match in ABS_PATH_RE.findall(scan):
resolved = pathlib.Path(strip_line_suffix(strip_fragment(match)))
if not resolved.exists():
broken_links.append(f"{rel}: missing absolute path {match}")
broken_paths.append(f"{rel}: missing absolute path {match}")

for target in MD_LINK_RE.findall(text):
# Relative markdown links are often illustrative examples in skill
# docs (a format spec citing ./src/ordering/CONTEXT.md), so their
# drift is advisory, not a rebuild-blocking failure.
for target in MD_LINK_RE.findall(scan):
resolved = resolve_target(path, target)
if resolved is None:
continue
if not resolved.exists():
broken_links.append(f"{rel}: missing link target {target}")
warn_links.append(f"{rel}: unresolved link target {target}")

if warn_links:
print("audit-doc-freshness: WARNING - unresolved doc link targets (advisory):", file=sys.stderr)
print("\n".join(warn_links), file=sys.stderr)

if stale_hits:
print("\n".join(stale_hits), file=sys.stderr)
fail("stale references found in active docs")

if broken_links:
print("\n".join(broken_links), file=sys.stderr)
fail("broken doc links found in active docs")
if broken_paths:
print("\n".join(broken_paths), file=sys.stderr)
fail("broken absolute paths found in active docs")

print("audit-doc-freshness: ok")

Expand Down
96 changes: 96 additions & 0 deletions bin/audit-hook-ownership.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# audit-hook-ownership.sh - deterministic per-harness hook-ownership audit.
#
# For every agent harness, resolve its hook config to the actual command
# target(s) it runs, then verify each target: it exists, is executable, and is
# declaratively OWNED (a home-manager symlink into the nix store or the repo),
# not a hand-placed regular file. Fails CLOSED: a missing repo root, or a
# missing / non-executable / unowned target, is a VIOLATION - never a silent
# skip. This replaces eyeball-grepping a harness directory (or a transcript log)
# to guess what a hook runs and who owns it.
#
# Exit: 0 all owned+executable, 1 one or more violations, 2 repo not locatable.
set -uo pipefail

HOME_DIR="${HOME:?}"

# --- resolve the dotfiles repo from this script's own location, fail closed ---
# Run from the repo (bin/…), $0 resolves inside it. Run from a deployed nix-store
# copy, that resolution lands in the store, so fall back to the conventional
# repo path - then require home.nix either way so a wrong root fails closed
# instead of silently auditing nothing (the old hardcoded-path failure mode).
resolve_repo() {
local self dir
self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "${BASH_SOURCE[0]}")
dir=$(cd "$(dirname "$self")/.." 2>/dev/null && pwd -P || true)
if [ -n "$dir" ] && [ -f "$dir/home.nix" ]; then printf '%s\n' "$dir"; return 0; fi
if [ -f "$HOME_DIR/dotfiles/home.nix" ]; then printf '%s\n' "$HOME_DIR/dotfiles"; return 0; fi
return 1
}
REPO=$(resolve_repo) || {
echo "audit-hook-ownership: FAIL closed - cannot locate the dotfiles repo (no home.nix)" >&2
exit 2
}

violations=0
ok() { printf 'OK %s\n' "$*"; }
bad() { printf 'VIOLATED %s\n' "$*"; violations=$((violations + 1)); }

# Classify a hook command target path by ownership.
classify_target() {
local t=$1 real
if [ -L "$t" ]; then
if [ ! -e "$t" ]; then echo "MISSING(dangling-symlink)"; return; fi
real=$(readlink -f "$t" 2>/dev/null || true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use portable symlink resolution for the audit

On the target macOS setup, the configured PATH only adds /opt/homebrew/bin rather than Homebrew coreutils' gnubin, so readlink is the BSD implementation and does not support -f. In that environment this assignment leaves real empty, causing every Home Manager symlink - including the newly deployed ~/bin/herdr-agent-state.sh - to be classified as UNOWNED(symlink-outside-nix-and-repo), so the new rebuild.sh gate fails even when the hook target is correctly deployed. Use a portable resolver (realpath, Python, or greadlink fallback) here.

Useful? React with 👍 / 👎.

case "$real" in
/nix/store/*) [ -x "$t" ] && echo "OWNED(nix)" || echo "OWNED-BUT-NOT-EXECUTABLE" ;;
"$REPO"/*) [ -x "$t" ] && echo "OWNED(repo)" || echo "OWNED-BUT-NOT-EXECUTABLE" ;;
*) echo "UNOWNED(symlink-outside-nix-and-repo)" ;;
esac
elif [ -e "$t" ]; then
echo "UNOWNED(hand-placed-regular-file)"
else
echo "MISSING(target-does-not-exist)"
fi
}

# Extract every absolute .sh command target from a hook config's command strings.
# jq '[.. | .command?]' finds command keys at ANY nesting (Claude/Codex/Cursor
# use a flat hooks map; Gemini nests under PreInvocation/PreToolUse), so one
# extractor handles every harness shape.
extract_targets() {
local cfg=$1
[ -f "$cfg" ] || return 0
jq -r '[.. | .command? // empty] | .[]' "$cfg" 2>/dev/null \
| grep -oE "/[^ \"']+\.sh" || true
}

# Audit one harness. Args: label config-file
audit_harness() {
local label=$1 cfg=$2 t cls found=0
if [ ! -f "$cfg" ]; then
printf -- '- %s: no hook config (%s) - nothing to load\n' "$label" "$cfg"
return 0
fi
while IFS= read -r t; do
[ -n "$t" ] || continue
found=1
cls=$(classify_target "$t")
case "$cls" in
OWNED\(*) ok "$label -> $t [$cls]" ;;
*) bad "$label -> $t [$cls]" ;;
esac
done < <(extract_targets "$cfg")
[ "$found" = 1 ] || printf -- '- %s: hook config present but declares no command targets\n' "$label"
}

echo "== hook-ownership audit (repo: $REPO) =="
audit_harness "claude(.claude)" "$HOME_DIR/.claude/settings.json"
audit_harness "claude(.claude-a)" "$HOME_DIR/.claude-a/settings.json"
audit_harness "claude(.claude-token)" "$HOME_DIR/.claude-token/settings.json"
audit_harness "codex" "$HOME_DIR/.codex/hooks.json"
audit_harness "cursor" "$HOME_DIR/.cursor/hooks.json"
audit_harness "gemini" "$HOME_DIR/.gemini/config/hooks.json"
echo "== $violations violation(s) =="

[ "$violations" -eq 0 ]
48 changes: 48 additions & 0 deletions bin/herdr-agent-state.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# herdr-agent-state.sh - neutral, repo-owned SessionStart hook for ANY agent
# harness (Claude, Codex, Cursor, ...). Registers this pane as an AI agent with
# herdr so the multiplexer can natively track busy/idle/blocked state.
#
# Harness-neutral by design: the caller passes --source and --label, so there is
# no hardcoded harness identity to copy per harness. One script, deployed once,
# owned declaratively by home-manager. Silent no-op outside a herdr pane, and
# always exit 0 - this is advisory and must never block a session from starting.
#
# Usage: herdr-agent-state.sh [phase] --source <id> --label <name>
# phase positional, defaults to "session" (e.g. session/startup)
# --source herdr agent source id (required in practice; falls back to --label)
# --label human-readable agent label (falls back to --source)
set -euo pipefail

phase="session"
source=""
label=""
while [ "$#" -gt 0 ]; do
case "$1" in
--source) source="${2:-}"; shift 2 ;;
--label) label="${2:-}"; shift 2 ;;
--source=*) source="${1#*=}"; shift ;;
--label=*) label="${1#*=}"; shift ;;
--) shift ;;
*) phase="$1"; shift ;;
esac
done

# Only active inside a herdr pane with a resolvable pane id.
[ -n "${HERDR_ENV:-}" ] || exit 0
PANE_ID="${HERDR_PANE_ID:-}"
[ -n "$PANE_ID" ] || exit 0

# A misconfigured caller (no source/label) still reports something generic
# rather than falling back to a hardcoded harness identity.
source="${source:-${label:-agent}}"
label="${label:-$source}"

herdr pane report-agent "$PANE_ID" \
--source "$source" \
--agent "$label" \
--state "idle" \
--message "$label session starting ($phase)" \
2>/dev/null || true

exit 0
9 changes: 7 additions & 2 deletions home.nix
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,6 @@ in
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.gemini/config/mcp_config.json";
home.file.".gemini/settings.json".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.gemini/settings.json";
home.file.".gemini/config/hooks.json".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.gemini/config/hooks.json";
home.file.".gemini/AGENTS.md".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/AGENTS.md";

Expand Down Expand Up @@ -243,6 +241,13 @@ in
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/bin/audit-config-ownership.sh";
home.file."bin/audit-doc-freshness.sh".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/bin/audit-doc-freshness.sh";
home.file."bin/audit-hook-ownership.sh".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/bin/audit-hook-ownership.sh";
# Neutral, harness-agnostic herdr SessionStart hook. One script, owned here,
# called by every harness's hook with its own --source/--label (replacing the
# hand-placed per-harness copies).
home.file."bin/herdr-agent-state.sh".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/bin/herdr-agent-state.sh";
home.file."bin/auto-save.sh".source =
config.lib.file.mkOutOfStoreSymlink "${dotfiles}/bin/auto-save.sh";
home.file."bin/fm-prep-context".source =
Expand Down
2 changes: 1 addition & 1 deletion home/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"hooks": [
{
"type": "command",
"command": "bash '/Users/jwalinshah/.claude/hooks/herdr-agent-state.sh' session",
"command": "bash '/Users/jwalinshah/bin/herdr-agent-state.sh' session --source claude-code --label claude-code",
"timeout": 10
}
]
Expand Down
2 changes: 1 addition & 1 deletion home/.codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{
"hooks": [
{
"command": "bash '/Users/jwalinshah/.codex/herdr-agent-state.sh' session",
"command": "bash '/Users/jwalinshah/bin/herdr-agent-state.sh' session --source codex --label codex",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh Codex hook trust with the new command

Changing this Codex hook command makes the existing trusted hash for /Users/jwalinshah/.codex/hooks.json:session_start:0:0 in home/.codex/config.toml stale. The Codex hooks docs state that non-managed hooks must be trusted before they run and that new or changed hooks are skipped until trusted, so after rebuild this SessionStart hook will warn/skip instead of reporting to herdr until /hooks is approved or the declarative trusted hash is updated.

Useful? React with 👍 / 👎.

"timeout": 10,
"type": "command"
}
Expand Down
Loading