Skip to content

Commit 2f80e0d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25012-cbp-write-path-master-edit
2 parents 44eef53 + 3b3830c commit 2f80e0d

20 files changed

Lines changed: 1072 additions & 511 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/service-settings': minor
3+
---
4+
5+
fix(service-settings)!: a settings value that misses the format its `email`, `url`, `phone` or `color` type declares is refused, both when it is saved and when it arrives as an `OS_*` override (#24882)
6+
7+
ADR-0007 declares a format for four specifier types: `email`, `url` and `phone` take "format validation", and `color` takes a hex string. Until now nothing enforced it. `PUT /api/settings/branding` stored a Logo URL of `not a url` and answered `200`, and an `OS_*` override was served whatever it held. Both doors now judge the format through one shared check, after every other declared constraint, and refuse a value that misses it.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which runtime values two settings doors accept: a value saved through `PUT /api/settings/:namespace` or `setMany`, and a deployment's `OS_*` environment override. Settings values are runtime state in `sys_setting` (ADR-0007) and environment values belong to the deployment, and no metadata conversion reaches either; a row already stored is not rewritten and is still served. The remedy is to supply a well-formed value, never a rewrite of a metadata source. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (the settings doors' accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
The formats are the platform's existing grammars for the same words:
16+
17+
- `email`: an address with a dotted domain, the grammar the mail transport sends with. `no-reply@localhost` misses it.
18+
- `url`: `scheme://…`, a root-relative `/path`, a `data:` URI or a `blob:` URL. A relative logo path that the branding reader serves is accepted.
19+
- `phone`: digits, spaces and `+ ( ) - .`, at least five characters.
20+
- `color`: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
21+
22+
Empty values and non-string values are not judged. Surrounding whitespace is ignored, because the readers trim it before use.
23+
24+
What changes:
25+
26+
- **Saving, FROM:** a save naming a value that misses its key's format answered `200` and stored the value as written.
27+
- **Saving, TO:** the save answers `400` with `error.code: 'SETTINGS_VALIDATION'` and one entry per such key in `error.details.fields`, carrying `code` `invalid_email`, `invalid_url`, `invalid_phone` or `invalid_format` (for `color`), and `constraint.type`. Nothing in the save is stored. The answer names the expected format and never echoes the value.
28+
- **Environment override, FROM:** a malformed `OS_*` override was in force: reads served it with source `env`, and it locked the key against saves.
29+
- **Environment override, TO:** a malformed `OS_*` override is no longer in force. The server logs one `error` line per variable and value, naming the variable, the setting, the type and the expected format, never the value. Reads serve the next layer instead: the stored value when one exists, otherwise the manifest default, with that layer as the source. The key is no longer locked, so it can be saved through Setup.
30+
- **Unchanged:** a well-formed value saves, and a well-formed override is in force and locks the key. A value that breaks another declared constraint (a length bound, a pattern) is still refused for that constraint. A value stored before this release is still served by every read, and it does not block a save of the namespace's other keys; only a save that writes that key, or a key its `visible` condition reads, judges it. An all-null save (reset) still clears it.
31+
- **What to do:** fix the value. For an `OS_*` override, set the variable to a well-formed value or unset it and configure the setting in Setup. For a stored value, save a well-formed value for the key, or clear it.

‎.changeset/24882-settings-format-warning.md‎

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the npm page and the catalog's front page give the install line for the project's own package manager
6+
7+
Clause-②: no
8+
9+
`README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) printed `npm install --save-dev "@objectstack/skills@$(node -p …)"` as the only install line. In a pnpm-installed project that line exits `1` with `npm error Cannot read properties of null (reading 'edgesOut')` and writes nothing, and `create-objectstack` installs with pnpm when it is available, so a scaffolded project is commonly a pnpm project.
10+
11+
- **Both pages** now print `pnpm add -D "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"`, with the npm and yarn spellings on a comment line beside it, and the rule: add the package with the manager that installed the project. The `objectstack-upgrade` skill's re-install step, which an agent follows for this exact move, prints the same line.
12+
- **The npm page** names pnpm's `ERR_PNPM_NO_MATCHING_VERSION` beside npm's `ETARGET` for a project on `@objectstack/spec` 17.x, and runs `npx skills list` before `npx skills remove --all`, which removes every installed skill without asking.

‎.claude/hooks/guard-main-checkout-bash.selftest.sh‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,44 @@ expect block "echo \"\$(echo \"\$(printf '\"')\")\" ; sed -i s/a/b/ pkg/x.ts"
326326
# introducer behind it is found and the body stays prose.
327327
expect allow "$(printf 'echo "$(echo " #")" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
328328

329+
echo "== a \${…} inside \"…\" is its own quoting context too (#24901) =="
330+
CWD="$MAIN"
331+
# The measured false block: the `"` that opens the default was read as CLOSING the outer string,
332+
# so `b}` became a write target. Pure reads — nothing is written. Each one exercises a different
333+
# pass: tokenisation (the first two), segmentation (the `;`), nesting both ways (the third).
334+
expect allow 'echo "${x:-"a > b"}"'
335+
expect allow 'echo "${x#"a > b"}"'
336+
expect allow 'echo "${x:-"$(printf "a > b")"}"'
337+
expect allow 'echo "${x:-"a; sed -i s/a/b/ pkg/x.ts"}"'
338+
expect allow "echo \"\${x:-'a > b'}\"" # control: no nested `"`, allowed before and after
339+
# The heredoc scan agrees: the ` #` inside the expansion is no comment, so the introducer
340+
# behind it is found and the body stays prose.
341+
expect allow "$(printf 'echo "${x:-" #"}" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
342+
# Recall is untouched: a write behind, beside or inside the script of a quoted expansion. A
343+
# '…' run holding no character the shells disagree on is skipped, so the write behind it stays.
344+
expect block 'sed -i "s/a/${x:-"b"}/" pkg/x.ts'
345+
expect block 'sed -i "s/a/${x}/" pkg/x.ts'
346+
expect block 'echo "${HOME}" > pkg/x.ts'
347+
expect block 'echo "${x:-"a"}" ; sed -i s/a/b/ pkg/x.ts'
348+
expect block "echo \"\${x:-'a'}\" ; sed -i s/a/b/ pkg/x.ts"
349+
# A `$(…)` inside the expansion is read by the same function, quotes and all: the `"` inside
350+
# its single quotes closes nothing, so the separator behind the word still splits.
351+
expect block "echo \"\${x:-\$(printf '\"')}\" ; sed -i s/a/b/ pkg/x.ts"
352+
329353
echo "== shapes this guard deliberately does NOT claim (documented fail-open) =="
330354
CWD="$MAIN"
331355
expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\""
332356
expect allow 'echo pkg/x.ts | xargs sed -i s/a/b/'
333357
expect allow 'node -e "fs.writeFileSync(\"pkg/x.ts\", \"y\")"'
334358
expect allow 'python3 -c "open(\"pkg/x.ts\",\"w\").write(\"y\")"'
335359
expect allow 'echo "$(sed -i s/a/b/ pkg/x.ts)"' # a QUOTED $(…) body is one inert word
360+
expect allow 'echo "${x:-"$(sed -i s/a/b/ pkg/x.ts)"}"' # …and so is a QUOTED ${…} body
361+
expect allow 'echo hi > "${x:-"a > b"}"' # a target holding an expansion
362+
# A '…' run inside a quoted ${…} that holds `}` (or `"` `\` `$` and a backtick): bash 5.2 reads
363+
# it as quoted and POSIX shells as literal, so they end the expansion in different places and
364+
# everything after it is inert. Under bash the first is a pure read, under dash a real write.
365+
expect allow "echo \"\${x:-'}\"; sed -i s/a/b/ pkg/x.ts'}\""
366+
expect allow "echo \"\${x:-'}'}\" ; sed -i s/a/b/ pkg/x.ts"
336367
expect allow 'sed -i s/a/b/ $SOMEDIR/x.ts'
337368
expect allow 'sed -i s/a/b/ pkg/*.ts'
338369

‎.claude/hooks/guard-main-checkout-bash.sh‎

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
# node -e, awk > (the target is not a shell token, so there is nothing to read);
4545
# - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable
4646
# without running it;
47+
# - everything after a `'` inside a QUOTED "${…}" unless the quoted run holds none of
48+
# `"` `\` `$` `}` or a backtick: bash and POSIX shells disagree on where such an expansion
49+
# ends (subst_end());
4750
# - relative targets when the payload carries no cwd.
4851
# Each of those is a hole by choice, not by accident: OS_ALLOW_MAIN_EDITS=1 already exists
4952
# for anyone who means to write there, and the target of this guard is the reflexive
@@ -52,8 +55,8 @@
5255
# Writing ABOUT the ban must never trip the ban (#4890's lesson). Four layers:
5356
# 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is
5457
# literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass;
55-
# a `$(…)` or backtick inside "…" is a quoting context of its own, so a `"` in its body
56-
# nests instead of closing the outer string (subst_end(), shared by every pass);
58+
# a `$(…)`, a backtick or a `${…}` inside "…" is a quoting context of its own, so a `"` in
59+
# its body nests instead of closing the outer string (subst_end(), shared by every pass);
5760
# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF`
5861
# body are documentation, not commands, and segmentation alone (which splits on
5962
# newlines) would happily read them as such;
@@ -121,35 +124,66 @@ fi
121124

122125
[ -n "$cmd" ] || exit 0
123126

124-
# --- a command substitution inside "…" is its own quoting context ---------------------
125-
# Inside "…", `$(` or a backtick opens a NEW quoting context: a `"` in the body starts a string
126-
# of its own and does not close the outer one. Each scanner below used to read that `"` as the
127-
# closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was taken
128-
# for a redirect whose "target" was the next fragment — a false BLOCK (#24812). All three
129-
# quote-aware scanners hand such a body to this ONE function, so they agree on where it ends.
127+
# --- an expansion inside "…" is its own quoting context --------------------------------
128+
# Inside "…", `$(`, a backtick or `${` opens a NEW quoting context: a `"` in the body starts a
129+
# string of its own and does not close the outer one. Each scanner below used to read that `"`
130+
# as the closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was
131+
# taken for a redirect whose "target" was the next fragment — a false BLOCK (#24812); the same
132+
# reading of `echo "${x:-"a > b"}"` named `b}` as a target (#24901). All three quote-aware
133+
# scanners hand such a body to this ONE function, so they agree on where it ends.
134+
# guard-shared-stash.sh, guard-process-kill.sh and guard-tree-enum.sh segment commands the same
135+
# way and carry a byte-identical copy; their self-tests fail when a copy differs from this one.
130136
#
131-
# subst_end S I: S[I] is the `$` of a `$(`, or a backtick, met inside "…". Sets SUBST_END to
132-
# the index of the character that closes the substitution, reading the body with its own
133-
# quotes, escapes and nested `(…)` / `$(…)` / backticks; when the input ends first, to the
134-
# last index — the rest is one inert run (bash refuses such a command anyway). The body is
135-
# kept verbatim as part of the word, so a target holding it still fails open on its `$`.
136-
# Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment) moves the end —
137-
# too early, and the rest is read as it was before this function existed; too late, and the
138-
# text swallowed with the body is inert, i.e. fails open.
137+
# subst_end S I: when S[I] opens such a context (the `$` of a `$(` or a `${`, or a backtick),
138+
# sets SUBST_END to the index of the character that closes it and returns 0; otherwise returns 1
139+
# and changes nothing. The body is read with its own quotes, escapes and nesting; when the input
140+
# ends first, SUBST_END is the last index — the rest is one inert run (bash refuses such a
141+
# command anyway). The body is kept verbatim as part of the word, so a target holding it still
142+
# fails open on its `$`.
143+
#
144+
# A `${…}` body reads like "…" with one more closer: `"`, `$(`, `${` and a backtick nest in it,
145+
# a backslash escapes, and `}` closes it. A `'` there is where shells part ways, measured: bash
146+
# 5.2 reads `'…'` as a quoted run while it looks for the `}` (`"${x:-'}'}"` prints `'}'`), and
147+
# `bash --posix` and dash read the `'` as a literal. The two readings agree only when the run
148+
# holds none of `"` `\` `$` `}` or a backtick, so such a run is skipped, and any other `'` makes
149+
# the rest one inert run: an extent that depends on the shell is not one this guard can read.
150+
#
151+
# Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment, an unquoted `${…}`
152+
# holding one) moves the end — too early, and the rest is read as it was before this function
153+
# existed; too late, and the text swallowed with the body is inert, i.e. fails open.
139154
SUBST_END=0
140155
subst_end() {
141-
local s="$1" i="$2" n=${#1} st ch top
142-
if [ "${s:i:1}" = '`' ]; then st='`'; else st=')' ; i=$((i + 1)); fi
156+
local s="$1" i="$2" n=${#1} st ch top run
157+
case "${s:i:2}" in
158+
'$(') st=')' ; i=$((i + 1)) ;;
159+
'${') st='}' ; i=$((i + 1)) ;;
160+
'`'*) st='`' ;;
161+
*) return 1 ;;
162+
esac
143163
for ((i = i + 1; i < n; i++)); do
144164
ch="${s:i:1}" ; top="${st:${#st}-1:1}"
145165
if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi
146166
if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi
147-
if [ "$top" = '"' ]; then
167+
if [ "$top" = '"' ] || [ "$top" = '}' ]; then
148168
case "$ch" in
149-
'"') st="${st%?}" ;;
169+
'"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;;
150170
'`') st+='`' ;;
151-
'$') if [ "${s:i+1:1}" = '(' ]; then st+=')' ; i=$((i + 1)); fi ;;
171+
'$')
172+
case "${s:i+1:1}" in
173+
'(') st+=')' ; i=$((i + 1)) ;;
174+
'{') st+='}' ; i=$((i + 1)) ;;
175+
esac ;;
176+
'}') [ "$top" = '}' ] && st="${st%?}" ;;
177+
"'")
178+
if [ "$top" = '}' ]; then
179+
run="${s:i+1}"
180+
case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac
181+
run="${run%%"'"*}"
182+
case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac
183+
i=$((i + ${#run} + 1))
184+
fi ;;
152185
esac
186+
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
153187
continue
154188
fi
155189
case "$ch" in
@@ -158,9 +192,10 @@ subst_end() {
158192
')') [ "$top" = ')' ] && st="${st%?}" ;;
159193
'`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;;
160194
esac
161-
if [ -z "$st" ]; then SUBST_END=$i; return; fi
195+
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
162196
done
163197
SUBST_END=$((n - 1))
198+
return 0
164199
}
165200

166201
# --- heredoc bodies are text, not commands ---------------------------------------------
@@ -193,8 +228,8 @@ strip_line_comment() {
193228
ch="${s:i:1}"
194229
if [ -n "$q" ]; then
195230
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi
196-
if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then
197-
subst_end "$s" "$i" ; i=$SUBST_END ; continue
231+
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
232+
i=$SUBST_END ; continue
198233
fi
199234
[ "$ch" = "$q" ] && q=""
200235
continue
@@ -265,8 +300,8 @@ split_segments() {
265300
seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
266301
esac
267302
fi
268-
if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then
269-
subst_end "$s" "$i" ; seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue
303+
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
304+
seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue
270305
fi
271306
seg+="$ch"
272307
[ "$ch" = "$q" ] && q=""
@@ -325,8 +360,8 @@ tokenize() {
325360
i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;;
326361
esac
327362
fi
328-
if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then
329-
subst_end "$s" "$i" ; tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue
363+
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
364+
tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue
330365
fi
331366
if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi
332367
have=1

0 commit comments

Comments
 (0)