navori 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/core/core-assets/hooks/_partials/audit-log.sh +170 -0
- package/dist/assets/core/core-assets/hooks/audit-mode-trigger.sh +28 -80
- package/dist/assets/core/core-assets/hooks/guard-destructive.sh +102 -1
- package/dist/assets/core/core-assets/hooks/managed-drift-watch.sh +180 -0
- package/dist/assets/core/core-assets/hooks/precompact-session-summary.sh +40 -1
- package/dist/assets/core/core-assets/hooks/quality-gate-pre-commit.sh +37 -0
- package/dist/assets/core/core-assets/hooks/session-start-context.sh +46 -2
- package/dist/assets/core/core-assets/hooks/stop-verify-reminder.sh +37 -2
- package/dist/assets/core/core-assets/hooks/subagent-stop-handoff.sh +41 -2
- package/dist/assets/core/core-assets/hooks/worktree-reclaim.sh +180 -0
- package/dist/assets/core/core-assets/managed/operaciones-seguras.md +4 -1
- package/dist/assets/core/core-assets/settings/settings-base.json +1 -1
- package/dist/assets/plugins/jscpd/scripts/check-jscpd.sh +38 -1
- package/dist/assets/plugins/semgrep/scripts/check-semgrep.sh +32 -0
- package/dist/index.js +358 -338
- package/package.json +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Shared audit-mode event recorder — inlined into each managed hook at render
|
|
2
|
+
# time (see the include directive in the source scripts + lib/hook-includes.ts).
|
|
3
|
+
#
|
|
4
|
+
# WHY (spec 0013): a hook is only visible to the transcript when it BLOCKS or
|
|
5
|
+
# INJECTS context. Every hook that runs and lets the action through is invisible,
|
|
6
|
+
# so `navori audit` could never answer "did the gate run, and what did it cost?".
|
|
7
|
+
# The transcript cannot be fixed — it is the host's format — so the harness
|
|
8
|
+
# records its own execution instead, and the session log becomes the source of
|
|
9
|
+
# truth for what the harness did.
|
|
10
|
+
#
|
|
11
|
+
# Expects `$payload` (the raw hook payload on stdin) to be in scope, and reads
|
|
12
|
+
# `$navori_audit_t0` for the start instant. Both are set by `navori_audit_begin`.
|
|
13
|
+
#
|
|
14
|
+
# Every variable read here carries a `:-` default ON PURPOSE: most managed hooks
|
|
15
|
+
# run under `set -euo pipefail`, where an unset variable ABORTS the hook. A
|
|
16
|
+
# recorder that can abort the thing it observes is worse than no recorder, so the
|
|
17
|
+
# partial must be safe to inline into `set -u` and `set +e` alike.
|
|
18
|
+
#
|
|
19
|
+
# FAIL-OPEN ABSOLUTE, and this matters more here than anywhere else: this code is
|
|
20
|
+
# inlined into hooks whose own contract is to never break a session. Observation
|
|
21
|
+
# must never become the reason an action fails, so every path returns 0 and
|
|
22
|
+
# nothing is ever written to stdout — a stray byte there would be interpreted by
|
|
23
|
+
# the host as hook output (context injection, or a block reason).
|
|
24
|
+
|
|
25
|
+
# Start the clock — but only after establishing that anything will be recorded.
|
|
26
|
+
#
|
|
27
|
+
# THE COST OF BEING OFF is the number that matters here: this code is inlined
|
|
28
|
+
# into hooks that fire on EVERY Bash call, and audit-mode is off for virtually
|
|
29
|
+
# every session of every user. An earlier version ran `perl` plus two `jq`
|
|
30
|
+
# invocations before it ever checked whether a log existed — three processes per
|
|
31
|
+
# hook, four hooks per command, ~48 ms on every single shell call for a feature
|
|
32
|
+
# nobody had turned on.
|
|
33
|
+
#
|
|
34
|
+
# So the gate is a pure-builtin one first: the per-repo audit directory only
|
|
35
|
+
# exists once audit-mode has been activated in this repo at least once. No
|
|
36
|
+
# subprocess, no parsing. Everything expensive lives behind it.
|
|
37
|
+
navori_audit_begin() {
|
|
38
|
+
navori_audit_on=0
|
|
39
|
+
|
|
40
|
+
navori_audit_root=${NAVORI_AUDITS_ROOT:-}
|
|
41
|
+
if [ -z "$navori_audit_root" ]; then
|
|
42
|
+
[ -n "${HOME:-}" ] || return 0
|
|
43
|
+
navori_audit_root=$HOME/.navori/audits
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# The gate tests the audit ROOT, not the per-repo directory.
|
|
47
|
+
#
|
|
48
|
+
# Deriving the repo cheaply would mean `${CLAUDE_PROJECT_DIR##*/}` — and that
|
|
49
|
+
# is WRONG: the authoritative repo comes from the payload's `cwd`, and the two
|
|
50
|
+
# differ whenever a hook fires inside an agent worktree, since the hook process
|
|
51
|
+
# starts in the main repo (#454). A gate built on the wrong name would silently
|
|
52
|
+
# record nothing exactly where the harness runs its parallel work.
|
|
53
|
+
#
|
|
54
|
+
# The root alone is enough for what this gate is for: a user who has never
|
|
55
|
+
# activated audit-mode anywhere has no `~/.navori/audits`, so the common case
|
|
56
|
+
# costs one stat and zero processes. Someone who does use audit-mode pays the
|
|
57
|
+
# parsing — which is the cost of the feature they turned on.
|
|
58
|
+
[ -d "$navori_audit_root" ] || return 0
|
|
59
|
+
|
|
60
|
+
navori_audit_on=1
|
|
61
|
+
navori_audit_t0=$(navori_audit_now)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Milliseconds since epoch, spending a process only when it has to.
|
|
65
|
+
#
|
|
66
|
+
# `$EPOCHREALTIME` is a BUILTIN in bash 5 and in zsh (with zsh/datetime, which
|
|
67
|
+
# the harness's zsh path already has): no fork at all. Only a shell without it
|
|
68
|
+
# pays for `perl`, and only a machine without perl degrades to whole seconds —
|
|
69
|
+
# `date` has no portable millisecond format (GNU has %s%3N, BSD does not).
|
|
70
|
+
navori_audit_now() {
|
|
71
|
+
if [ -n "${EPOCHREALTIME:-}" ]; then
|
|
72
|
+
# `1756... .123456` → milliseconds, with pure parameter expansion.
|
|
73
|
+
navori_audit_epoch=${EPOCHREALTIME/,/.}
|
|
74
|
+
printf '%s%s' "${navori_audit_epoch%%.*}" "$(printf '%.3s' "${navori_audit_epoch#*.}")"
|
|
75
|
+
return 0
|
|
76
|
+
fi
|
|
77
|
+
perl -MTime::HiRes=time -e 'printf "%.0f", time*1000' 2>/dev/null \
|
|
78
|
+
|| printf '%s' $(( $(date +%s 2>/dev/null || echo 0) * 1000 ))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# navori_audit_log <verdict> [reason]
|
|
82
|
+
#
|
|
83
|
+
# `name`, `phase`, `tool` and `source` come from the caller's own variables,
|
|
84
|
+
# which the render sets per hook — the partial never guesses which hook it is
|
|
85
|
+
# inlined into.
|
|
86
|
+
navori_audit_log() {
|
|
87
|
+
# The builtin-only gate from `navori_audit_begin`: when audit-mode was never
|
|
88
|
+
# activated in this repo, nothing below runs and no process is spawned.
|
|
89
|
+
[ "${navori_audit_on:-0}" = "1" ] || return 0
|
|
90
|
+
# No payload, no jq, no clock → nothing to record. Each of these is a normal
|
|
91
|
+
# state for a hook that bailed early, not an error worth surfacing.
|
|
92
|
+
[ -n "${payload:-}" ] || return 0
|
|
93
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
94
|
+
|
|
95
|
+
# ONE jq for every field, not one per field: this runs on each hook of each
|
|
96
|
+
# Bash call, and a fork is the most expensive thing in it. Newline-separated,
|
|
97
|
+
# read back positionally.
|
|
98
|
+
navori_audit_fields=$(printf '%s' "${payload:-}" | jq -r '[.session_id // "", .cwd // "", .agent_id // .subagent_id // ""] | .[]' 2>/dev/null) || return 0
|
|
99
|
+
navori_audit_session=${navori_audit_fields%%
|
|
100
|
+
*}
|
|
101
|
+
navori_audit_rest=${navori_audit_fields#*
|
|
102
|
+
}
|
|
103
|
+
navori_audit_cwd=${navori_audit_rest%%
|
|
104
|
+
*}
|
|
105
|
+
navori_audit_agent=${navori_audit_rest#*
|
|
106
|
+
}
|
|
107
|
+
[ -n "$navori_audit_session" ] || return 0
|
|
108
|
+
# Same character class the CLI enforces (#503): the id composes a path, so
|
|
109
|
+
# anything path-shaped means the payload is not what we think it is.
|
|
110
|
+
case "$navori_audit_session" in
|
|
111
|
+
*[!A-Za-z0-9_-]*) return 0 ;;
|
|
112
|
+
esac
|
|
113
|
+
|
|
114
|
+
[ -n "$navori_audit_cwd" ] || navori_audit_cwd=$PWD
|
|
115
|
+
navori_audit_repo=$(basename "$navori_audit_cwd" 2>/dev/null) || return 0
|
|
116
|
+
[ -n "$navori_audit_repo" ] || return 0
|
|
117
|
+
|
|
118
|
+
navori_audit_file=$navori_audit_root/$navori_audit_repo/session-$navori_audit_session.log
|
|
119
|
+
|
|
120
|
+
# The session may not be the marked one even in a repo that has been audited
|
|
121
|
+
# before. Also the writability check — a log that cannot be appended to is not
|
|
122
|
+
# an error, it is simply not recording.
|
|
123
|
+
[ -f "$navori_audit_file" ] || return 0
|
|
124
|
+
[ -w "$navori_audit_file" ] || return 0
|
|
125
|
+
|
|
126
|
+
# Volume valve, OFF by default.
|
|
127
|
+
#
|
|
128
|
+
# `PreToolUse(Bash)` chains four hooks, so every shell command leaves four
|
|
129
|
+
# lines and most are `skip` — a long session runs to thousands. Set
|
|
130
|
+
# NAVORI_AUDIT_SKIP_NOOPS=1 to drop the ones that did nothing.
|
|
131
|
+
#
|
|
132
|
+
# Default off on purpose: a `skip` is the ONLY evidence that a hook ran and
|
|
133
|
+
# decided it had no business acting, which is exactly what distinguishes it
|
|
134
|
+
# from a hook that never executed — the question that motivated recording
|
|
135
|
+
# hooks at all. The valve trades that away knowingly; it must not be the
|
|
136
|
+
# silent default.
|
|
137
|
+
if [ "${NAVORI_AUDIT_SKIP_NOOPS:-0}" = "1" ]; then
|
|
138
|
+
case "$1" in
|
|
139
|
+
skip|noop) return 0 ;;
|
|
140
|
+
esac
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
navori_audit_end=$(navori_audit_now)
|
|
144
|
+
navori_audit_ms=$(( navori_audit_end - ${navori_audit_t0:-$navori_audit_end} ))
|
|
145
|
+
[ "$navori_audit_ms" -ge 0 ] 2>/dev/null || navori_audit_ms=0
|
|
146
|
+
|
|
147
|
+
navori_audit_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || navori_audit_ts=""
|
|
148
|
+
# `navori_audit_agent` came out of the same single jq above. It is what lets
|
|
149
|
+
# the report attribute a hook to a subagent WITHOUT guessing: with agents
|
|
150
|
+
# running in parallel their time windows overlap, so attribution by timestamp
|
|
151
|
+
# is the fallback, not the primary route.
|
|
152
|
+
|
|
153
|
+
printf '%s\n' "$(jq -cn \
|
|
154
|
+
--arg ts "$navori_audit_ts" \
|
|
155
|
+
--arg name "${navori_audit_name:-unknown}" \
|
|
156
|
+
--arg phase "${navori_audit_phase:-unknown}" \
|
|
157
|
+
--arg verdict "${1:-unknown}" \
|
|
158
|
+
--arg reason "${2:-}" \
|
|
159
|
+
--arg tool "${navori_audit_tool:-}" \
|
|
160
|
+
--arg src "${navori_audit_source:-core}" \
|
|
161
|
+
--arg agent "${navori_audit_agent:-}" \
|
|
162
|
+
--argjson ms "$navori_audit_ms" \
|
|
163
|
+
'{ts:$ts,event:"hook",name:$name,phase:$phase,verdict:$verdict,ms:$ms,source:$src}
|
|
164
|
+
+ (if $tool == "" then {} else {tool:$tool} end)
|
|
165
|
+
+ (if $reason == "" then {} else {reason:$reason} end)
|
|
166
|
+
+ (if $agent == "" then {} else {agentId:$agent} end)' 2>/dev/null)" \
|
|
167
|
+
>> "$navori_audit_file" 2>/dev/null
|
|
168
|
+
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# navori — audit-mode
|
|
2
|
+
# navori — audit-mode prompt recorder (UserPromptSubmit)
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# mode by itself: a false positive must die in the question, leaving no state
|
|
7
|
-
# on disk.
|
|
4
|
+
# While audit-mode is active, appends the typed prompt to the session's
|
|
5
|
+
# append-only log. That is its ONLY job.
|
|
8
6
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# It used to also detect an audit-mode invocation in the prompt text and ask
|
|
8
|
+
# Claude to confirm activation. That was removed (spec 0013, R3): matching
|
|
9
|
+
# `audit mode` as a substring cannot separate INVOKING the mode from TALKING
|
|
10
|
+
# ABOUT it, and talking about it is what you do all day while working on the
|
|
11
|
+
# feature. The asymmetry made it worse — turning it ON matched loosely, while
|
|
12
|
+
# turning it OFF required the literal phrase, so sessions stayed open forever.
|
|
13
|
+
# Activation is now exclusively `navori audit --start <id>`.
|
|
14
|
+
#
|
|
15
|
+
# Writes are O_APPEND only; the log is never re-read to be rewritten, so
|
|
16
|
+
# parallel subagents cannot corrupt it and a crashed session still leaves a
|
|
17
|
+
# valid (merely shorter) file.
|
|
13
18
|
#
|
|
14
19
|
# FAIL-OPEN ABSOLUTE: this hook runs on every prompt. Any error, any missing
|
|
15
20
|
# dependency, any odd path exits 0 silently. It must never be the reason a
|
|
@@ -17,34 +22,6 @@
|
|
|
17
22
|
|
|
18
23
|
set +e
|
|
19
24
|
|
|
20
|
-
emit_and_exit() { printf '%s\n' "$1"; exit 0; }
|
|
21
|
-
|
|
22
|
-
# Is `audit` actually available in the CLI on PATH?
|
|
23
|
-
#
|
|
24
|
-
# The hook orders the agent to run `navori audit --start`, and that resolves the
|
|
25
|
-
# PUBLISHED binary, never a working tree's build. When the installed version
|
|
26
|
-
# predates the subcommand, citty prints the help and exits 0 — so an agent that
|
|
27
|
-
# checks the exit code reads a silent no-op as success and reports a recording
|
|
28
|
-
# that never started. Match the subcommand inside the CLI's own USAGE line
|
|
29
|
-
# instead of trusting the status.
|
|
30
|
-
#
|
|
31
|
-
# Returns 1 when the subcommand is absent AND when the check itself cannot run
|
|
32
|
-
# (no binary on PATH, no USAGE line). Both collapse into "could not confirm",
|
|
33
|
-
# which is what the caller's message must say: claiming "your version is old"
|
|
34
|
-
# would be wrong for a machine with no navori installed at all.
|
|
35
|
-
audit_subcommand_available() {
|
|
36
|
-
command -v navori >/dev/null 2>&1 || return 1
|
|
37
|
-
usage=$(navori --help 2>/dev/null | grep -m1 '^USAGE' 2>/dev/null) || return 1
|
|
38
|
-
[ -n "$usage" ] || return 1
|
|
39
|
-
# Normalize separators so the token matches at any position: `USAGE navori
|
|
40
|
-
# init|add|audit` -> `|USAGE|navori|init|add|audit|`.
|
|
41
|
-
tokens=$(printf '%s' "$usage" | tr ' ' '|' 2>/dev/null) || return 1
|
|
42
|
-
case "|$tokens|" in
|
|
43
|
-
*"|audit|"*) return 0 ;;
|
|
44
|
-
*) return 1 ;;
|
|
45
|
-
esac
|
|
46
|
-
}
|
|
47
|
-
|
|
48
25
|
payload=$(cat 2>/dev/null) || exit 0
|
|
49
26
|
[ -n "$payload" ] || exit 0
|
|
50
27
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
@@ -84,49 +61,20 @@ else
|
|
|
84
61
|
fi
|
|
85
62
|
log_file=$audits_root/$repo/session-$session_id.log
|
|
86
63
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
# one costs the whole recording.
|
|
91
|
-
case "$lower" in
|
|
92
|
-
*"audit mode"*|*"audit-mode"*|*"modo audit"*|*"modo auditoría"*|*"modo auditoria"*) matched=1 ;;
|
|
93
|
-
*) matched=0 ;;
|
|
94
|
-
esac
|
|
95
|
-
|
|
96
|
-
case "$lower" in
|
|
97
|
-
*apaga*|*apagar*|*desactiva*|*"salir de"*|*detén*|*deten*|*stop*|*"turn off"*|*disable*) off_intent=1 ;;
|
|
98
|
-
*) off_intent=0 ;;
|
|
99
|
-
esac
|
|
100
|
-
|
|
101
|
-
if [ -f "$log_file" ]; then
|
|
102
|
-
# Active: record the human's own words — they entered the model's context,
|
|
103
|
-
# so they cost tokens and belong in the audit.
|
|
104
|
-
#
|
|
105
|
-
# `transcript_path` rides along because the payload is the ONLY place it is
|
|
106
|
-
# stated. Without it the reader has to guess the transcript's location by
|
|
107
|
-
# re-deriving Claude Code's undocumented directory encoding (see paths.ts),
|
|
108
|
-
# and a guess that misses costs the whole report.
|
|
109
|
-
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || ts=""
|
|
110
|
-
transcript=$(printf '%s' "$payload" | jq -r '.transcript_path // ""' 2>/dev/null) || transcript=""
|
|
111
|
-
printf '%s\n' "$(jq -cn --arg ts "$ts" --arg ev "prompt" --arg p "$prompt" --arg tr "$transcript" \
|
|
112
|
-
'{ts:$ts,event:$ev,prompt:$p} + (if $tr == "" then {} else {transcript:$tr} end)' 2>/dev/null)" >> "$log_file" 2>/dev/null
|
|
64
|
+
# Not marked → not recording. This is also what makes the hook free outside
|
|
65
|
+
# audit-mode: one stat and out.
|
|
66
|
+
[ -f "$log_file" ] || exit 0
|
|
113
67
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if
|
|
125
|
-
if audit_subcommand_available; then
|
|
126
|
-
emit_and_exit "[navori audit-mode] An audit-mode invocation was detected in the prompt. Before activating anything, ask the user explicitly: \"an audit mode invocation was detected, continue?\". Only if they confirm, run: navori audit --start $session_id (that creates the session log). Then check the output: it must name the log file it created. If it prints the command list (USAGE) instead, the installed CLI has no such subcommand — tell the user and do NOT assume the mode is active. If they decline, run nothing and carry on with the task."
|
|
127
|
-
else
|
|
128
|
-
emit_and_exit "[navori audit-mode] An audit-mode invocation was detected, but the available 'navori' could not be confirmed to ship the 'audit' subcommand: it may not be installed at all, or it may predate that command. Do NOT run 'navori audit --start' blindly: if the binary exists but is old, it prints its help and exits 0, which looks like success without being one. Tell the user that nothing was activated, and why. Then carry on with the task."
|
|
129
|
-
fi
|
|
130
|
-
fi
|
|
68
|
+
# Record the human's own words — they entered the model's context, so they cost
|
|
69
|
+
# tokens and belong in the audit.
|
|
70
|
+
#
|
|
71
|
+
# `transcript_path` rides along because the payload is the ONLY place it is
|
|
72
|
+
# stated. Without it the reader has to guess the transcript's location by
|
|
73
|
+
# re-deriving Claude Code's undocumented directory encoding (see paths.ts),
|
|
74
|
+
# and a guess that misses costs the whole report.
|
|
75
|
+
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || ts=""
|
|
76
|
+
transcript=$(printf '%s' "$payload" | jq -r '.transcript_path // ""' 2>/dev/null) || transcript=""
|
|
77
|
+
printf '%s\n' "$(jq -cn --arg ts "$ts" --arg ev "prompt" --arg p "$prompt" --arg tr "$transcript" \
|
|
78
|
+
'{ts:$ts,event:$ev,prompt:$p} + (if $tr == "" then {} else {transcript:$tr} end)' 2>/dev/null)" >> "$log_file" 2>/dev/null
|
|
131
79
|
|
|
132
80
|
exit 0
|
|
@@ -30,7 +30,43 @@ set -euo pipefail
|
|
|
30
30
|
# A missing JSON parser used to make this guard wave every command through; the
|
|
31
31
|
# shared extractor falls back to sed so it still inspects the command.
|
|
32
32
|
# navori:include extract-cmd
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
navori_audit_name="guard-destructive"
|
|
35
|
+
navori_audit_phase="PreToolUse"
|
|
36
|
+
navori_audit_tool="Bash"
|
|
37
|
+
# Fallback no-ops, overwritten by the real definitions the include brings in.
|
|
38
|
+
# They exist because this hook is FAIL-OPEN: if the file ever runs WITHOUT its
|
|
39
|
+
# includes expanded — a raw copy of the asset, a render that half-finished — an
|
|
40
|
+
# undefined function would be exit 127, and under `set -e` that KILLS the hook.
|
|
41
|
+
# A recorder that can kill the thing it observes is the one bug this partial may
|
|
42
|
+
# never have.
|
|
43
|
+
navori_audit_begin() { :; }
|
|
44
|
+
navori_audit_log() { :; }
|
|
45
|
+
# navori:include audit-log
|
|
46
|
+
navori_audit_begin
|
|
47
|
+
|
|
48
|
+
# The verdict is resolved in a trap, NOT by a call at the end of the file.
|
|
49
|
+
#
|
|
50
|
+
# This hook's managed block ends before its `navori:user-section`, and the render
|
|
51
|
+
# only syncs what is INSIDE the block — so a call placed after it lives in the
|
|
52
|
+
# user's own territory and never reaches the mirror. That is exactly how the most
|
|
53
|
+
# critical hook in the harness ended up being the only one not recording.
|
|
54
|
+
#
|
|
55
|
+
# The trap also covers the extra guards a user writes in that section: whatever
|
|
56
|
+
# they add, the exit code still tells the truth about what happened.
|
|
57
|
+
navori_audit_verdict="allow"
|
|
58
|
+
navori_audit_reason=""
|
|
59
|
+
navori_audit_on_exit() {
|
|
60
|
+
navori_audit_log "$navori_audit_verdict" "$navori_audit_reason" || true
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
trap navori_audit_on_exit EXIT
|
|
64
|
+
|
|
65
|
+
if [ -z "$cmd" ]; then
|
|
66
|
+
navori_audit_verdict="skip"
|
|
67
|
+
navori_audit_reason="sin comando que inspeccionar"
|
|
68
|
+
exit 0
|
|
69
|
+
fi
|
|
34
70
|
|
|
35
71
|
# branchBase is shell-quoted at render time via the shq: marker (#197): a
|
|
36
72
|
# hostile branchBase in navori.config.json lands here as an inert literal,
|
|
@@ -43,6 +79,11 @@ block() {
|
|
|
43
79
|
# should carry, and the reason line already says which rule fired.
|
|
44
80
|
echo "[navori] command: ${cmd:0:2000}" >&2
|
|
45
81
|
echo "[navori] if intentional, run the command yourself outside the agent." >&2
|
|
82
|
+
# Only ASSIGNMENTS here: this is the one place the guard says no, and nothing
|
|
83
|
+
# may come between the decision and `exit 2`. The recording happens in the
|
|
84
|
+
# trap, after the exit is already committed.
|
|
85
|
+
navori_audit_verdict="block"
|
|
86
|
+
navori_audit_reason="$1"
|
|
46
87
|
exit 2
|
|
47
88
|
}
|
|
48
89
|
|
|
@@ -633,6 +674,66 @@ if printf '%s' "$live" | grep -qE '(of=/dev/(sd|nvme|disk|hd)|>[[:space:]]*/dev/
|
|
|
633
674
|
block "direct write to a block device"
|
|
634
675
|
fi
|
|
635
676
|
|
|
677
|
+
# 6. Shell rewrites of a file navori MAINTAINS (#530). In auto mode every edit
|
|
678
|
+
# arrives as a shell command — `sed -i`, a heredoc, a `>` redirect — instead
|
|
679
|
+
# of the `Edit` tool, and the two fail very differently: `Edit` aborts when
|
|
680
|
+
# the old text doesn't match (a check that the agent understood the file),
|
|
681
|
+
# while `sed -i` with a pattern that matches nothing exits 0 and a `>` with
|
|
682
|
+
# the wrong path truncates the file. On a managed file the damage is the #523
|
|
683
|
+
# shape: the body changes, its `hash=` no longer matches, navori marks the
|
|
684
|
+
# block `user-modified` and STOPS UPDATING IT. Nothing announces that.
|
|
685
|
+
#
|
|
686
|
+
# These files are a MIRROR, and the harness already says so in prose ("los
|
|
687
|
+
# conflictos en `.claude/` y CLAUDE.md no se resuelven a mano: son espejo").
|
|
688
|
+
# This rule is that policy made executable. The way to change them is to edit
|
|
689
|
+
# the source asset and run `navori render --apply`, or `navori sync` to
|
|
690
|
+
# reconcile — neither of which matches here, because neither redirects.
|
|
691
|
+
#
|
|
692
|
+
# COVERED (blocked), by FORM of the write:
|
|
693
|
+
# > path · >| path truncating redirect (`>>` append is NOT: it adds
|
|
694
|
+
# after the blocks and invalidates no hash)
|
|
695
|
+
# sed -i … path in-place rewrite
|
|
696
|
+
# tee path overwrite (`tee -a` is an append, so it is not)
|
|
697
|
+
# …and by TARGET, the marker-carrying outputs of `ENGINE_OUTPUTS`
|
|
698
|
+
# (lib/health.ts), which is what "managed" means anywhere else in navori:
|
|
699
|
+
# CLAUDE.md · AGENTS.md · .claude/settings.json
|
|
700
|
+
# .claude/agents · .claude/skills · .claude/hooks
|
|
701
|
+
# .agents/skills · .codex/config.toml · .codex/agents · .codex/hooks
|
|
702
|
+
# .cursor/rules
|
|
703
|
+
#
|
|
704
|
+
# NOT COVERED, and each exclusion is load-bearing:
|
|
705
|
+
# .claude/progress/… and .codex/progress/… the agent handoff files, for
|
|
706
|
+
# BOTH engines. Every subagent ends by writing one, usually with a
|
|
707
|
+
# heredoc; blocking that breaks the harness's own protocol with the
|
|
708
|
+
# harness's own guard. They carry no markers, so nothing there can be
|
|
709
|
+
# invalidated. Naming `.codex/` wholesale was exactly that bug: it
|
|
710
|
+
# swallowed `.codex/progress/` and would have silenced every Codex
|
|
711
|
+
# subagent's handoff (#389's rule caught it).
|
|
712
|
+
# .claude/settings.local.json · .claude/worktrees/ machine-local, never
|
|
713
|
+
# rendered, never marker-managed.
|
|
714
|
+
# cp/mv INTO a managed path, `python -c` writes, `awk > file`, `perl -i`.
|
|
715
|
+
# Enumerating write verbs is the losing half of this fight — the same
|
|
716
|
+
# "describe the danger by its textual form" pattern the blind audit found
|
|
717
|
+
# eight times. The PostToolUse watcher is the half that doesn't care about
|
|
718
|
+
# form: it re-checks the hashes AFTER the fact, so anything that gets past
|
|
719
|
+
# this rule still surfaces. This rule is the seatbelt; that one is the net.
|
|
720
|
+
managed_dir='\.claude/(agents|skills|hooks)|\.agents/skills|\.codex/(agents|hooks)|\.cursor/rules'
|
|
721
|
+
managed_path="(CLAUDE\.md|AGENTS\.md|\.claude/settings\.json|\.codex/config\.toml|(${managed_dir})/[^[:space:];&|]+)"
|
|
722
|
+
# The redirect check reads `scan`, NOT `segments`: the split rewrites every `|`
|
|
723
|
+
# into a newline, so `>| CLAUDE.md` (forced clobber) would be torn in half and
|
|
724
|
+
# the target would land in a segment of its own. Reading the unsplit copy is
|
|
725
|
+
# safe HERE and nowhere else in this file, because the pattern is local — only
|
|
726
|
+
# whitespace may sit between the `>` and its target, so it cannot reach across a
|
|
727
|
+
# `&&` into another command the way rules 1-3 could.
|
|
728
|
+
# `-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*` accepts the backup-suffix spellings that
|
|
729
|
+
# are the everyday form on both platforms: GNU `sed -i.bak`, BSD `sed -i ''`.
|
|
730
|
+
# Missing them would have left the rule covering the tutorial spelling only.
|
|
731
|
+
if printf '%s' "$scan" | grep -qE "(^|[^>])>\|?[[:space:]]*(\./)?${managed_path}([[:space:]]|\$)" \
|
|
732
|
+
|| printf '%s' "$segments" | grep -qE "(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*|--in-place)([[:space:]]|=).*${managed_path}" \
|
|
733
|
+
|| printf '%s' "$segments" | grep -qE "(^|[[:space:]])tee[[:space:]]+([^-][^[:space:]]*[[:space:]]+)*(\./)?${managed_path}([[:space:]]|\$)"; then
|
|
734
|
+
block "shell rewrite of a navori-managed file — edit the source asset and run 'navori render --apply' (or 'navori sync'); a direct write invalidates the block hash and freezes it"
|
|
735
|
+
fi
|
|
736
|
+
|
|
636
737
|
# navori:user-section
|
|
637
738
|
# user: add extra guards here. `$cmd` already holds the full command (compound
|
|
638
739
|
# commands included) and `block "<reason>"` aborts with exit 2.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# PostToolUse(Bash) watcher for managed-block drift (#530).
|
|
4
|
+
#
|
|
5
|
+
# THIS HOOK NEVER READS THE COMMAND. That is the whole design. Its sibling
|
|
6
|
+
# `guard-destructive.sh` decides by the SHAPE of what you typed, so it only ever
|
|
7
|
+
# covers the write verbs someone enumerated — `>`, `sed -i`, `tee` — and misses
|
|
8
|
+
# `python -c`, `perl -i`, `awk > file`, a formatter, or a script the agent
|
|
9
|
+
# didn't write. The blind audit found that "describe the danger by its textual
|
|
10
|
+
# form" pattern eight times, four of them with a test pinning the hole shut.
|
|
11
|
+
#
|
|
12
|
+
# So this one asks the only question that has no form: AFTER the command ran,
|
|
13
|
+
# do navori's managed blocks still hash to what their markers claim? A block
|
|
14
|
+
# whose body changed is a block navori will refuse to update from now on
|
|
15
|
+
# (`user-modified-skipped`) — the #523 freeze, arriving in silence. Whatever
|
|
16
|
+
# rewrote it, legitimately or not, this notices.
|
|
17
|
+
#
|
|
18
|
+
# The managed block below is regenerated by `navori render` and must NOT be
|
|
19
|
+
# edited by hand.
|
|
20
|
+
#
|
|
21
|
+
# SCOPE. settings.json invokes hooks as `bash "$CLAUDE_PROJECT_DIR/.claude/…"`,
|
|
22
|
+
# so this process starts in the MAIN repo even when the command ran inside an
|
|
23
|
+
# agent worktree (#454). Checking the main mirror is the right default — that is
|
|
24
|
+
# the copy every session shares and the one a stray write actually freezes — but
|
|
25
|
+
# a block broken INSIDE a worktree is not seen here. It surfaces when that branch
|
|
26
|
+
# is rendered or reviewed.
|
|
27
|
+
#
|
|
28
|
+
# COST, and why this compares CONTENT rather than mtimes. The obvious cheap
|
|
29
|
+
# check is `find -newer <stamp>`, and it is wrong here: `find` compares mtimes at
|
|
30
|
+
# whatever resolution the filesystem stores, which on several (ext4 under the CI
|
|
31
|
+
# runner among them) is ONE SECOND. Two writes inside the same second as the
|
|
32
|
+
# stamp are invisible — the watcher would report the first and silently miss the
|
|
33
|
+
# second, which is precisely the failure it exists to prevent. It passed on APFS
|
|
34
|
+
# and failed in CI, so the clock was never a sound basis.
|
|
35
|
+
#
|
|
36
|
+
# So the common case costs one `shasum` pass over the managed files (~25ms
|
|
37
|
+
# measured over 60 files) and compares that list against the previous one. It is
|
|
38
|
+
# exact, it depends on no clock, and per-block hashing — the expensive part, ~3.5s
|
|
39
|
+
# for 53 blocks — runs ONLY for the files whose content actually changed, which
|
|
40
|
+
# in a normal session is none and in a bad one is one.
|
|
41
|
+
set -uo pipefail
|
|
42
|
+
|
|
43
|
+
# PostToolUse delivers its payload on stdin; this hook never needed it and the
|
|
44
|
+
# audit recorder does (session_id/cwd), so it is captured rather than ignored.
|
|
45
|
+
payload=$(cat 2>/dev/null) || payload=""
|
|
46
|
+
|
|
47
|
+
navori_audit_name="managed-drift-watch"
|
|
48
|
+
navori_audit_phase="PostToolUse"
|
|
49
|
+
navori_audit_tool="Bash"
|
|
50
|
+
# Fallback no-ops, overwritten by the real definitions the include brings in.
|
|
51
|
+
# They exist because this hook is FAIL-OPEN: if the file ever runs WITHOUT its
|
|
52
|
+
# includes expanded — a raw copy of the asset, a render that half-finished — an
|
|
53
|
+
# undefined function would be exit 127, and under `set -e` that KILLS the hook.
|
|
54
|
+
# A recorder that can kill the thing it observes is the one bug this partial may
|
|
55
|
+
# never have.
|
|
56
|
+
navori_audit_begin() { :; }
|
|
57
|
+
navori_audit_log() { :; }
|
|
58
|
+
# navori:include audit-log
|
|
59
|
+
navori_audit_begin
|
|
60
|
+
|
|
61
|
+
# This hook has SEVEN early exits (no sha, no roots, no changes, …). Wiring a
|
|
62
|
+
# call into each is how the set drifts the next time one is added, so the verdict
|
|
63
|
+
# is derived once, from the exit code, in a trap.
|
|
64
|
+
#
|
|
65
|
+
# `navori_audit_reached_check` is what separates "ran and found nothing" from
|
|
66
|
+
# "bailed before checking anything" — the distinction the whole exercise exists
|
|
67
|
+
# for. Without it every early exit would report `clean`, i.e. would claim a
|
|
68
|
+
# verification that never happened.
|
|
69
|
+
navori_audit_reached_check=0
|
|
70
|
+
navori_audit_on_exit() {
|
|
71
|
+
navori_audit_code=$?
|
|
72
|
+
if [ "$navori_audit_code" -eq 2 ]; then
|
|
73
|
+
navori_audit_log "dirty" "bloque managed con hash desalineado"
|
|
74
|
+
elif [ "$navori_audit_reached_check" -eq 1 ]; then
|
|
75
|
+
navori_audit_log "clean"
|
|
76
|
+
else
|
|
77
|
+
navori_audit_log "skip" "sin nada que verificar"
|
|
78
|
+
fi
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
trap navori_audit_on_exit EXIT
|
|
82
|
+
|
|
83
|
+
cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0
|
|
84
|
+
|
|
85
|
+
stamp=".claude/.managed-drift-stamp"
|
|
86
|
+
|
|
87
|
+
# sha1 tool, resolved once. `shasum` on macOS, `sha1sum` on most Linuxes; both
|
|
88
|
+
# print `<hash> <path>` for a file list, which is the format the stamp stores.
|
|
89
|
+
sha=""
|
|
90
|
+
command -v shasum >/dev/null 2>&1 && sha="shasum -a 1"
|
|
91
|
+
[ -z "$sha" ] && command -v sha1sum >/dev/null 2>&1 && sha="sha1sum"
|
|
92
|
+
# No sha tool: stay silent. This is a detector, not a gate — the guard still
|
|
93
|
+
# sits in front, and a noisy failure here would fire on every single command.
|
|
94
|
+
[ -z "$sha" ] && exit 0
|
|
95
|
+
|
|
96
|
+
# The marker-carrying outputs of `ENGINE_OUTPUTS` (lib/health.ts) — the same
|
|
97
|
+
# list the guard's rule 6 protects, and for the same reason.
|
|
98
|
+
#
|
|
99
|
+
# `.claude/progress/` and `.codex/progress/` are deliberately absent for BOTH
|
|
100
|
+
# engines: those are agent handoff files, they carry no markers, and every
|
|
101
|
+
# subagent writes one — including them would re-hash a fresh report on every
|
|
102
|
+
# command and find nothing. `.claude/worktrees/` too: each is a full checkout,
|
|
103
|
+
# so walking it would make this hook cost more than the command it follows. That
|
|
104
|
+
# is why `.codex` is NOT listed wholesale.
|
|
105
|
+
roots=""
|
|
106
|
+
for r in CLAUDE.md AGENTS.md .claude/settings.json .claude/agents .claude/skills .claude/hooks .agents/skills .codex/config.toml .codex/agents .codex/hooks .cursor/rules; do
|
|
107
|
+
[ -e "$r" ] && roots="$roots $r"
|
|
108
|
+
done
|
|
109
|
+
[ -z "$roots" ] && exit 0
|
|
110
|
+
|
|
111
|
+
# shellcheck disable=SC2086 — word splitting is how the root list is passed.
|
|
112
|
+
current=$(find $roots -type f -exec $sha {} + 2>/dev/null | sort || true)
|
|
113
|
+
[ -z "$current" ] && exit 0
|
|
114
|
+
|
|
115
|
+
# First run in a session (or after the ephemeral dir was wiped): adopt the
|
|
116
|
+
# current state as the baseline and say nothing. Reporting every block on the
|
|
117
|
+
# first command would train the reader to ignore this hook by lunchtime.
|
|
118
|
+
if [ ! -f "$stamp" ]; then
|
|
119
|
+
mkdir -p .claude 2>/dev/null || exit 0
|
|
120
|
+
printf '%s\n' "$current" > "$stamp" 2>/dev/null || true
|
|
121
|
+
exit 0
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Lines present now but not in the baseline: a file whose CONTENT changed, or a
|
|
125
|
+
# new one. A deleted file appears only in the baseline and is ignored — there is
|
|
126
|
+
# no block left to verify.
|
|
127
|
+
changed=$(printf '%s\n' "$current" | grep -F -x -v -f "$stamp" 2>/dev/null | sed -E 's/^[a-f0-9]+[[:space:]]+//' || true)
|
|
128
|
+
# Re-baseline BEFORE reporting, so one write is reported once instead of on
|
|
129
|
+
# every command for the rest of the session. A second write does change the
|
|
130
|
+
# content again, and does get its own report.
|
|
131
|
+
printf '%s\n' "$current" > "$stamp" 2>/dev/null || true
|
|
132
|
+
[ -z "$changed" ] && exit 0
|
|
133
|
+
|
|
134
|
+
# From here on the work is per-CHANGED-file only. `$(…)` strips the trailing
|
|
135
|
+
# newline, which is what makes these hashes agree with `computeManagedHash`
|
|
136
|
+
# (lib/marker.ts: sha1 over the body with trailing whitespace removed, first 8
|
|
137
|
+
# hex); without it every hash differs and the hook cries wolf on healthy files.
|
|
138
|
+
drift=""
|
|
139
|
+
while IFS= read -r file; do
|
|
140
|
+
[ -n "$file" ] || continue
|
|
141
|
+
# Both marker syntaxes (lib/marker.ts): HTML for markdown, `#` for scripts and
|
|
142
|
+
# TOML. Anchored at the start of the line so a block QUOTED inside prose —
|
|
143
|
+
# this file's own header, a skill that documents the format — is not read as a
|
|
144
|
+
# real marker.
|
|
145
|
+
while IFS='|' read -r id declared; do
|
|
146
|
+
[ -n "$id" ] || continue
|
|
147
|
+
body=$(awk -v id="$id" '
|
|
148
|
+
$0 ~ ("^<!-- navori:managed id=\"" id "\"") { f=1; next }
|
|
149
|
+
$0 ~ ("^# navori:managed start id=\"" id "\"") { f=1; next }
|
|
150
|
+
$0 ~ ("^<!-- /navori:managed id=\"" id "\"") { f=0 }
|
|
151
|
+
$0 ~ ("^# navori:managed end id=\"" id "\"") { f=0 }
|
|
152
|
+
f' "$file")
|
|
153
|
+
actual=$(printf '%s' "$body" | $sha | cut -c1-8)
|
|
154
|
+
[ "$actual" = "$declared" ] || drift="${drift}
|
|
155
|
+
${file} block '${id}' (marker says ${declared}, content hashes ${actual})"
|
|
156
|
+
done <<EOF
|
|
157
|
+
$(grep -oE '^(<!-- navori:managed|# navori:managed start) id="[^"]+" hash="[a-f0-9]+"' "$file" 2>/dev/null \
|
|
158
|
+
| sed -E 's/.*id="([^"]+)" hash="([a-f0-9]+)".*/\1|\2/')
|
|
159
|
+
EOF
|
|
160
|
+
done <<EOF
|
|
161
|
+
$changed
|
|
162
|
+
EOF
|
|
163
|
+
|
|
164
|
+
navori_audit_reached_check=1
|
|
165
|
+
[ -z "$drift" ] && exit 0
|
|
166
|
+
|
|
167
|
+
# Exit 2 so the text reaches the model rather than scrolling past in a log: the
|
|
168
|
+
# whole failure mode being cured is that this goes unnoticed. The command has
|
|
169
|
+
# already run — nothing is being reverted or retried.
|
|
170
|
+
cat >&2 <<MSG
|
|
171
|
+
navori: a managed block no longer matches its marker hash.${drift}
|
|
172
|
+
|
|
173
|
+
navori will now treat those blocks as hand-edited and STOP updating them (the
|
|
174
|
+
#523 freeze). Nothing reverted this for you.
|
|
175
|
+
|
|
176
|
+
Recover with 'navori sync' (reconciles, shows the conflict diff) or
|
|
177
|
+
'navori render --apply' if the block should simply be regenerated. If the edit
|
|
178
|
+
was intentional, it belongs in the source asset, not in the rendered mirror.
|
|
179
|
+
MSG
|
|
180
|
+
exit 2
|
|
@@ -21,14 +21,53 @@
|
|
|
21
21
|
# The managed block is regenerated by `navori render`; do NOT edit by hand.
|
|
22
22
|
set -euo pipefail
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# The PreCompact JSON was drained and discarded here; it is kept now because the
|
|
25
|
+
# audit recorder reads `session_id`/`cwd` out of it. Draining is still the point:
|
|
26
|
+
# an undrained stdin can leave the host writing into a closed pipe.
|
|
27
|
+
payload=$(cat 2>/dev/null) || payload=""
|
|
28
|
+
|
|
29
|
+
navori_audit_name="precompact-session-summary"
|
|
30
|
+
navori_audit_phase="PreCompact"
|
|
31
|
+
# Fallback no-ops, overwritten by the real definitions the include brings in.
|
|
32
|
+
# They exist because this hook is FAIL-OPEN: if the file ever runs WITHOUT its
|
|
33
|
+
# includes expanded — a raw copy of the asset, a render that half-finished — an
|
|
34
|
+
# undefined function would be exit 127, and under `set -e` that KILLS the hook.
|
|
35
|
+
# A recorder that can kill the thing it observes is the one bug this partial may
|
|
36
|
+
# never have.
|
|
37
|
+
navori_audit_begin() { :; }
|
|
38
|
+
navori_audit_log() { :; }
|
|
39
|
+
# navori:include audit-log
|
|
40
|
+
navori_audit_begin
|
|
41
|
+
|
|
42
|
+
# The verdict is a VARIABLE resolved in a trap, not a call per branch. These
|
|
43
|
+
# hooks have several early exits each (no git, no worktrees, nothing to inject),
|
|
44
|
+
# and wiring a call into every one is how the set drifts the next time somebody
|
|
45
|
+
# adds an exit. Defaulting to `skip` makes a new early exit semantically correct
|
|
46
|
+
# for free: it means "ran, decided it had nothing to do", which is exactly what
|
|
47
|
+
# an unhandled early return is.
|
|
48
|
+
navori_audit_verdict="skip"
|
|
49
|
+
navori_audit_reason=""
|
|
50
|
+
navori_audit_on_exit() {
|
|
51
|
+
navori_audit_log "$navori_audit_verdict" "$navori_audit_reason" || true
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
trap navori_audit_on_exit EXIT
|
|
55
|
+
|
|
25
56
|
|
|
26
57
|
ctx="A punto de compactar el contexto: el detalle turno-a-turno se resumirá. Antes de perderlo, persiste un resumen de sesión con tu herramienta de memoria (engram: guarda el resumen de sesión) y/o anota las decisiones y los bugs con causa raíz de esta sesión en progress/current.md."
|
|
27
58
|
|
|
28
59
|
# Emit additionalContext safely: node (best escaping) → jq → give up (exit 0).
|
|
29
60
|
if command -v node >/dev/null 2>&1; then
|
|
30
61
|
CTX="$ctx" node -e 'process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"PreCompact",additionalContext:process.env.CTX}}))'
|
|
62
|
+
navori_audit_verdict="inject"
|
|
31
63
|
elif command -v jq >/dev/null 2>&1; then
|
|
32
64
|
jq -n --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreCompact",additionalContext:$ctx}}'
|
|
65
|
+
navori_audit_verdict="inject"
|
|
66
|
+
else
|
|
67
|
+
# Neither serializer available: the reminder never reached the model. Recorded
|
|
68
|
+
# as `noop` rather than `inject`, because the audit must not claim a context
|
|
69
|
+
# injection that did not happen.
|
|
70
|
+
navori_audit_verdict="noop"
|
|
71
|
+
navori_audit_reason="sin node ni jq: el recordatorio no se emitio"
|
|
33
72
|
fi
|
|
34
73
|
exit 0
|