planning-with-files 3.10.2 → 3.11.2

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.
@@ -1,206 +1,206 @@
1
- #!/bin/sh
2
- # planning-with-files: lock the current task_plan.md content with a SHA-256 attestation.
3
- #
4
- # Use after you finalise (or intentionally edit) a plan. The hooks then refuse
5
- # to inject plan content into the model context if the file diverges from the
6
- # attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
7
- #
8
- # Resolution:
9
- # 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
10
- # 2. ./.planning/.active_plan
11
- # 3. Newest ./.planning/<dir>/ by mtime
12
- # 4. Legacy ./task_plan.md at project root
13
- #
14
- # Usage:
15
- # sh scripts/attest-plan.sh # attest the active plan
16
- # sh scripts/attest-plan.sh --show # print the stored hash
17
- # sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
18
-
19
- set -u
20
-
21
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
- RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
23
-
24
- resolve_plan_file() {
25
- plan_dir=""
26
- if [ -f "${RESOLVER}" ]; then
27
- plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
28
- fi
29
- if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
30
- printf "%s\n" "${plan_dir}/task_plan.md"
31
- return 0
32
- fi
33
- if [ -f "./task_plan.md" ]; then
34
- printf "%s\n" "./task_plan.md"
35
- return 0
36
- fi
37
- return 1
38
- }
39
-
40
- attestation_path_for() {
41
- plan_file="$1"
42
- plan_dir="$(dirname "${plan_file}")"
43
- if [ "${plan_dir}" = "." ]; then
44
- # Legacy mode: store at project root.
45
- printf "%s\n" "./.plan-attestation"
46
- else
47
- printf "%s\n" "${plan_dir}/.attestation"
48
- fi
49
- }
50
-
51
- compute_hash() {
52
- target="$1"
53
- if command -v sha256sum >/dev/null 2>&1; then
54
- sha256sum "${target}" | awk '{print $1}'
55
- elif command -v shasum >/dev/null 2>&1; then
56
- shasum -a 256 "${target}" | awk '{print $1}'
57
- else
58
- printf "ERROR: no sha256 utility available\n" >&2
59
- return 1
60
- fi
61
- }
62
-
63
- mode="attest"
64
- case "${1:-}" in
65
- --show) mode="show" ;;
66
- --clear) mode="clear" ;;
67
- "") mode="attest" ;;
68
- *)
69
- printf "Usage: %s [--show|--clear]\n" "$0" >&2
70
- exit 2
71
- ;;
72
- esac
73
-
74
- plan_file="$(resolve_plan_file)" || {
75
- printf "[plan-attest] No task_plan.md found. Create a plan first.\n" >&2
76
- exit 1
77
- }
78
-
79
- attestation_file="$(attestation_path_for "${plan_file}")"
80
-
81
- case "${mode}" in
82
- show)
83
- if [ -f "${attestation_file}" ]; then
84
- printf "Plan: %s\n" "${plan_file}"
85
- printf "Attestation: %s\n" "${attestation_file}"
86
- printf "SHA-256: %s\n" "$(cat "${attestation_file}")"
87
- # Nonce (security A1.4): if init-session generated a per-plan nonce
88
- # next to the attestation, surface it. Informational only here; the
89
- # hooks consume it to build collision-proof BEGIN/END delimiters.
90
- nonce_file="$(dirname "${attestation_file}")/.nonce"
91
- if [ -f "${nonce_file}" ]; then
92
- printf "Nonce: %s\n" "$(tr -d '\r\n[:space:]' < "${nonce_file}" 2>/dev/null)"
93
- fi
94
- else
95
- printf "[plan-attest] No attestation set for %s.\n" "${plan_file}"
96
- exit 1
97
- fi
98
- ;;
99
- clear)
100
- if [ -f "${attestation_file}" ]; then
101
- rm -f "${attestation_file}"
102
- printf "[plan-attest] Cleared attestation for %s.\n" "${plan_file}"
103
- else
104
- printf "[plan-attest] No attestation to clear.\n"
105
- fi
106
- ;;
107
- attest)
108
- hash_val="$(compute_hash "${plan_file}")" || exit 1
109
-
110
- # v2.40: protect the write with an advisory flock when available so
111
- # concurrent legacy-mode sessions (no PLAN_ID, both at the same project
112
- # root) cannot corrupt the .plan-attestation file mid-write. Atomic
113
- # rename of a temp file is the real guarantee on POSIX; flock is the
114
- # cooperative gate around the rename for slow-disk writes.
115
- #
116
- # Note: legacy single-file mode is inherently racey across concurrent
117
- # sessions because both can edit task_plan.md without coordination. The
118
- # canonical parallel-session pattern is slug-mode under
119
- # .planning/<slug>/, where each session pins PLAN_ID and gets its own
120
- # .attestation file. We surface a hint when concurrent activity is
121
- # detected.
122
- if [ -f "${attestation_file}" ]; then
123
- mtime_now="$(date +%s 2>/dev/null || echo 0)"
124
- mtime_prev="$(stat -c '%Y' "${attestation_file}" 2>/dev/null \
125
- || stat -f '%m' "${attestation_file}" 2>/dev/null \
126
- || echo 0)"
127
- age=$((mtime_now - mtime_prev))
128
- if [ "${age}" -ge 0 ] && [ "${age}" -lt 30 ] 2>/dev/null; then
129
- # If we're in legacy mode (root .plan-attestation) and another
130
- # session just wrote, warn. Slug-mode files in .planning/<slug>/
131
- # are per-session by construction; no need to warn there.
132
- case "${attestation_file}" in
133
- *./.plan-attestation|*/.plan-attestation)
134
- case "${attestation_file}" in
135
- *./.planning/*) : ;; # slug-mode, ignore
136
- *)
137
- printf "[plan-attest] Note: %s was modified %ss ago by another process.\n" \
138
- "${attestation_file}" "${age}" >&2
139
- printf "[plan-attest] For parallel sessions, prefer slug-mode (init-session.sh <name>) so each session gets its own .attestation file.\n" >&2
140
- ;;
141
- esac
142
- ;;
143
- esac
144
- fi
145
- fi
146
-
147
- tmp_file="${attestation_file}.tmp.$$"
148
- printf "%s\n" "${hash_val}" > "${tmp_file}" 2>/dev/null || {
149
- printf "[plan-attest] Failed to write %s\n" "${tmp_file}" >&2
150
- exit 1
151
- }
152
- mv_ok=1
153
- if command -v flock >/dev/null 2>&1; then
154
- # Advisory lock around the rename. lock_dir is the dir containing
155
- # the target file. The {} subshell pattern keeps the lock scoped to
156
- # the mv call.
157
- lock_dir="$(dirname "${attestation_file}")"
158
- (
159
- flock -w 5 9 || true
160
- mv -f "${tmp_file}" "${attestation_file}"
161
- ) 9>"${lock_dir}/.attestation.lock" 2>/dev/null || mv_ok=0
162
- rm -f "${lock_dir}/.attestation.lock" 2>/dev/null
163
- else
164
- mv -f "${tmp_file}" "${attestation_file}" 2>/dev/null || mv_ok=0
165
- fi
166
-
167
- # Integrity gap fix (security A2.1): a failed atomic rename must not be
168
- # allowed to silently leave a stale attestation when the target already
169
- # existed. The old fallback only wrote when the file was absent, so a
170
- # cross-device or permission-denied mv on an existing attestation left
171
- # the OLD hash in place with a success exit. On mv failure we re-write
172
- # the intended hash through a second atomic rename (never a bare
173
- # redirect onto the live file, which would expose torn reads to
174
- # concurrent verifiers), then verify the on-disk content.
175
- if [ "${mv_ok}" -eq 0 ] || [ ! -f "${attestation_file}" ]; then
176
- fb_tmp="${attestation_file}.fb.$$"
177
- printf "%s\n" "${hash_val}" > "${fb_tmp}" 2>/dev/null \
178
- && mv -f "${fb_tmp}" "${attestation_file}" 2>/dev/null || {
179
- rm -f "${fb_tmp}" "${tmp_file}" 2>/dev/null
180
- printf "[plan-attest] Failed to write attestation %s\n" "${attestation_file}" >&2
181
- exit 1
182
- }
183
- fi
184
- rm -f "${tmp_file}" 2>/dev/null
185
-
186
- # Read-back verification. Both write paths above are atomic renames, so
187
- # a concurrent verifier always reads a complete 64-hex hash — either our
188
- # own or an identical one from a peer attesting the same plan content.
189
- # A mismatch here therefore means our intended hash genuinely did not
190
- # land (stale content, failed write); fail loudly with a nonzero exit so
191
- # callers never trust a stale attestation.
192
- stored_hash="$(tr -d '\r\n[:space:]' < "${attestation_file}" 2>/dev/null)"
193
- if [ "${stored_hash}" != "${hash_val}" ]; then
194
- printf "[plan-attest] Attestation write verification FAILED for %s\n" "${attestation_file}" >&2
195
- printf "[plan-attest] Expected %s, found %s. The plan is NOT attested.\n" "${hash_val}" "${stored_hash}" >&2
196
- exit 1
197
- fi
198
-
199
- short_hash="$(printf "%s" "${hash_val}" | cut -c1-12)"
200
- printf "[plan-attest] Locked %s\n" "${plan_file}"
201
- printf "[plan-attest] SHA-256: %s... (stored in %s)\n" "${short_hash}" "${attestation_file}"
202
- printf "[plan-attest] Hooks will block injection if the file is modified without re-running this command.\n"
203
- ;;
204
- esac
205
-
206
- exit 0
1
+ #!/bin/sh
2
+ # planning-with-files: lock the current task_plan.md content with a SHA-256 attestation.
3
+ #
4
+ # Use after you finalise (or intentionally edit) a plan. The hooks then refuse
5
+ # to inject plan content into the model context if the file diverges from the
6
+ # attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
7
+ #
8
+ # Resolution:
9
+ # 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
10
+ # 2. ./.planning/.active_plan
11
+ # 3. Newest ./.planning/<dir>/ by mtime
12
+ # 4. Legacy ./task_plan.md at project root
13
+ #
14
+ # Usage:
15
+ # sh scripts/attest-plan.sh # attest the active plan
16
+ # sh scripts/attest-plan.sh --show # print the stored hash
17
+ # sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
18
+
19
+ set -u
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+ RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
23
+
24
+ resolve_plan_file() {
25
+ plan_dir=""
26
+ if [ -f "${RESOLVER}" ]; then
27
+ plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
28
+ fi
29
+ if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
30
+ printf "%s\n" "${plan_dir}/task_plan.md"
31
+ return 0
32
+ fi
33
+ if [ -f "./task_plan.md" ]; then
34
+ printf "%s\n" "./task_plan.md"
35
+ return 0
36
+ fi
37
+ return 1
38
+ }
39
+
40
+ attestation_path_for() {
41
+ plan_file="$1"
42
+ plan_dir="$(dirname "${plan_file}")"
43
+ if [ "${plan_dir}" = "." ]; then
44
+ # Legacy mode: store at project root.
45
+ printf "%s\n" "./.plan-attestation"
46
+ else
47
+ printf "%s\n" "${plan_dir}/.attestation"
48
+ fi
49
+ }
50
+
51
+ compute_hash() {
52
+ target="$1"
53
+ if command -v sha256sum >/dev/null 2>&1; then
54
+ sha256sum "${target}" | awk '{print $1}'
55
+ elif command -v shasum >/dev/null 2>&1; then
56
+ shasum -a 256 "${target}" | awk '{print $1}'
57
+ else
58
+ printf "ERROR: no sha256 utility available\n" >&2
59
+ return 1
60
+ fi
61
+ }
62
+
63
+ mode="attest"
64
+ case "${1:-}" in
65
+ --show) mode="show" ;;
66
+ --clear) mode="clear" ;;
67
+ "") mode="attest" ;;
68
+ *)
69
+ printf "Usage: %s [--show|--clear]\n" "$0" >&2
70
+ exit 2
71
+ ;;
72
+ esac
73
+
74
+ plan_file="$(resolve_plan_file)" || {
75
+ printf "[plan-attest] No task_plan.md found. Create a plan first.\n" >&2
76
+ exit 1
77
+ }
78
+
79
+ attestation_file="$(attestation_path_for "${plan_file}")"
80
+
81
+ case "${mode}" in
82
+ show)
83
+ if [ -f "${attestation_file}" ]; then
84
+ printf "Plan: %s\n" "${plan_file}"
85
+ printf "Attestation: %s\n" "${attestation_file}"
86
+ printf "SHA-256: %s\n" "$(cat "${attestation_file}")"
87
+ # Nonce (security A1.4): if init-session generated a per-plan nonce
88
+ # next to the attestation, surface it. Informational only here; the
89
+ # hooks consume it to build collision-proof BEGIN/END delimiters.
90
+ nonce_file="$(dirname "${attestation_file}")/.nonce"
91
+ if [ -f "${nonce_file}" ]; then
92
+ printf "Nonce: %s\n" "$(tr -d '\r\n[:space:]' < "${nonce_file}" 2>/dev/null)"
93
+ fi
94
+ else
95
+ printf "[plan-attest] No attestation set for %s.\n" "${plan_file}"
96
+ exit 1
97
+ fi
98
+ ;;
99
+ clear)
100
+ if [ -f "${attestation_file}" ]; then
101
+ rm -f "${attestation_file}"
102
+ printf "[plan-attest] Cleared attestation for %s.\n" "${plan_file}"
103
+ else
104
+ printf "[plan-attest] No attestation to clear.\n"
105
+ fi
106
+ ;;
107
+ attest)
108
+ hash_val="$(compute_hash "${plan_file}")" || exit 1
109
+
110
+ # v2.40: protect the write with an advisory flock when available so
111
+ # concurrent legacy-mode sessions (no PLAN_ID, both at the same project
112
+ # root) cannot corrupt the .plan-attestation file mid-write. Atomic
113
+ # rename of a temp file is the real guarantee on POSIX; flock is the
114
+ # cooperative gate around the rename for slow-disk writes.
115
+ #
116
+ # Note: legacy single-file mode is inherently racey across concurrent
117
+ # sessions because both can edit task_plan.md without coordination. The
118
+ # canonical parallel-session pattern is slug-mode under
119
+ # .planning/<slug>/, where each session pins PLAN_ID and gets its own
120
+ # .attestation file. We surface a hint when concurrent activity is
121
+ # detected.
122
+ if [ -f "${attestation_file}" ]; then
123
+ mtime_now="$(date +%s 2>/dev/null || echo 0)"
124
+ mtime_prev="$(stat -c '%Y' "${attestation_file}" 2>/dev/null \
125
+ || stat -f '%m' "${attestation_file}" 2>/dev/null \
126
+ || echo 0)"
127
+ age=$((mtime_now - mtime_prev))
128
+ if [ "${age}" -ge 0 ] && [ "${age}" -lt 30 ] 2>/dev/null; then
129
+ # If we're in legacy mode (root .plan-attestation) and another
130
+ # session just wrote, warn. Slug-mode files in .planning/<slug>/
131
+ # are per-session by construction; no need to warn there.
132
+ case "${attestation_file}" in
133
+ *./.plan-attestation|*/.plan-attestation)
134
+ case "${attestation_file}" in
135
+ *./.planning/*) : ;; # slug-mode, ignore
136
+ *)
137
+ printf "[plan-attest] Note: %s was modified %ss ago by another process.\n" \
138
+ "${attestation_file}" "${age}" >&2
139
+ printf "[plan-attest] For parallel sessions, prefer slug-mode (init-session.sh <name>) so each session gets its own .attestation file.\n" >&2
140
+ ;;
141
+ esac
142
+ ;;
143
+ esac
144
+ fi
145
+ fi
146
+
147
+ tmp_file="${attestation_file}.tmp.$$"
148
+ printf "%s\n" "${hash_val}" > "${tmp_file}" 2>/dev/null || {
149
+ printf "[plan-attest] Failed to write %s\n" "${tmp_file}" >&2
150
+ exit 1
151
+ }
152
+ mv_ok=1
153
+ if command -v flock >/dev/null 2>&1; then
154
+ # Advisory lock around the rename. lock_dir is the dir containing
155
+ # the target file. The {} subshell pattern keeps the lock scoped to
156
+ # the mv call.
157
+ lock_dir="$(dirname "${attestation_file}")"
158
+ (
159
+ flock -w 5 9 || true
160
+ mv -f "${tmp_file}" "${attestation_file}"
161
+ ) 9>"${lock_dir}/.attestation.lock" 2>/dev/null || mv_ok=0
162
+ rm -f "${lock_dir}/.attestation.lock" 2>/dev/null
163
+ else
164
+ mv -f "${tmp_file}" "${attestation_file}" 2>/dev/null || mv_ok=0
165
+ fi
166
+
167
+ # Integrity gap fix (security A2.1): a failed atomic rename must not be
168
+ # allowed to silently leave a stale attestation when the target already
169
+ # existed. The old fallback only wrote when the file was absent, so a
170
+ # cross-device or permission-denied mv on an existing attestation left
171
+ # the OLD hash in place with a success exit. On mv failure we re-write
172
+ # the intended hash through a second atomic rename (never a bare
173
+ # redirect onto the live file, which would expose torn reads to
174
+ # concurrent verifiers), then verify the on-disk content.
175
+ if [ "${mv_ok}" -eq 0 ] || [ ! -f "${attestation_file}" ]; then
176
+ fb_tmp="${attestation_file}.fb.$$"
177
+ printf "%s\n" "${hash_val}" > "${fb_tmp}" 2>/dev/null \
178
+ && mv -f "${fb_tmp}" "${attestation_file}" 2>/dev/null || {
179
+ rm -f "${fb_tmp}" "${tmp_file}" 2>/dev/null
180
+ printf "[plan-attest] Failed to write attestation %s\n" "${attestation_file}" >&2
181
+ exit 1
182
+ }
183
+ fi
184
+ rm -f "${tmp_file}" 2>/dev/null
185
+
186
+ # Read-back verification. Both write paths above are atomic renames, so
187
+ # a concurrent verifier always reads a complete 64-hex hash — either our
188
+ # own or an identical one from a peer attesting the same plan content.
189
+ # A mismatch here therefore means our intended hash genuinely did not
190
+ # land (stale content, failed write); fail loudly with a nonzero exit so
191
+ # callers never trust a stale attestation.
192
+ stored_hash="$(tr -d '\r\n[:space:]' < "${attestation_file}" 2>/dev/null)"
193
+ if [ "${stored_hash}" != "${hash_val}" ]; then
194
+ printf "[plan-attest] Attestation write verification FAILED for %s\n" "${attestation_file}" >&2
195
+ printf "[plan-attest] Expected %s, found %s. The plan is NOT attested.\n" "${hash_val}" "${stored_hash}" >&2
196
+ exit 1
197
+ fi
198
+
199
+ short_hash="$(printf "%s" "${hash_val}" | cut -c1-12)"
200
+ printf "[plan-attest] Locked %s\n" "${plan_file}"
201
+ printf "[plan-attest] SHA-256: %s... (stored in %s)\n" "${short_hash}" "${attestation_file}"
202
+ printf "[plan-attest] Hooks will block injection if the file is modified without re-running this command.\n"
203
+ ;;
204
+ esac
205
+
206
+ exit 0