diff --git a/AGENTS.md b/AGENTS.md index ea442a11..7cedc529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -392,8 +392,11 @@ optional. Filesystem inventories preserve complete filenames with NUL-delimited Agent identity and installed-source checks share a minimum frontmatter observer. It requires a closed header, unique top-level keys, text-valued agent identity, and a usable GitHub repository at -the direct `metadata.github-repo` key. Unsupported header syntax is refused; this observer does not -replace the skill specification validator. The bundled-edit guard uses the same source observation +the direct `metadata.github-repo` key. Plain identity values reject reserved YAML indicators and +mapping separators. Literal and folded identity blocks require a valid single indentation/chomping +declaration and content meeting the declared or inferred indentation. Unsupported header syntax is +refused; this observer does not replace the skill specification validator. The bundled-edit guard uses +the same source observation at the base commit and preserves whole path components, including embedded and trailing newlines. Body examples never supply provenance, and malformed base provenance remains UNKNOWN. diff --git a/scripts/frontmatter.awk b/scripts/frontmatter.awk index 68420a0f..06cfa7aa 100644 --- a/scripts/frontmatter.awk +++ b/scripts/frontmatter.awk @@ -1,5 +1,7 @@ # Observe the supported scalar/mapping header shape, never claim full YAML validation. function trim(s) { sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s } +# YAML separation uses ASCII spaces and tabs; Unicode spaces are scalar content. +function yaml_trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s } # Normalize the same Unicode whitespace observed in escaped scalars for presence # only. Preserve the original scalar for provenance; use complete UTF-8 strings # so the C and UTF-8 locales agree without partial-byte regex ranges. @@ -56,7 +58,7 @@ function quoted_text(s,q, i,c,n,hex,j,d,code,out) { quoted_ok=1; return out } function scalar(s, q,i,c,escaped,tail) { - s=trim(s); scalar_ok=0 + s=yaml_trim(s); scalar_ok=0 q=substr(s,1,1) if (q == "\"" || q == "\047") { for (i=2;i<=length(s);i++) { @@ -64,7 +66,7 @@ function scalar(s, q,i,c,escaped,tail) { if (q == "\"" && !escaped && c == "\\") { escaped=1; continue } if (!escaped && c == q) { if (q == "\047" && substr(s,i+1,1) == q) { i++; continue } - tail=trim(substr(s,i+1)) + tail=yaml_trim(substr(s,i+1)) if (tail != "" && substr(tail,1,1) != "#") return "" s=quoted_text(substr(s,2,i-2),q) scalar_ok=(quoted_ok && nonblank_text(s)); return s @@ -73,8 +75,12 @@ function scalar(s, q,i,c,escaped,tail) { } return "" } - sub(/[[:space:]]+#.*$/,"",s); s=trim(s) - if (s == "" || substr(s,1,1) == "#" || s ~ /^[\[\{&*!|>]/ || + sub(/[ \t]+#.*$/,"",s); s=yaml_trim(s) + # A plain scalar cannot start with a reserved indicator, contain a mapping + # separator, or turn its first token into a sequence/mapping declaration. + # The same punctuation remains valid inside supported quoted scalars. + if (s == "" || s ~ /^[\[\]\{\},#&*!|>%@`]/ || + s ~ /^[-?:]([ \t]|$)/ || s ~ /:([ \t]|$)/ || s ~ /^(~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/ || s ~ /^[-+]?([0-9][0-9_]*(\.[0-9_]*)?|\.[0-9_]+)([eE][-+]?[0-9_]+)?$/ || s ~ /^[-+]?0([xX][0-9a-fA-F_]+|[oO][0-7_]+|[bB][01_]+)$/ || @@ -88,16 +94,46 @@ function scalar(s, q,i,c,escaped,tail) { if (NR == 1) { if ($0 !~ /^---[[:space:]]*$/) bad=1; next } if ($0 ~ /^---[[:space:]]*$/) { closed=1; exit } if (bad) next - if (mode == "text" && block && $0 ~ /^[[:space:]]/ && nonblank_text($0)) found=1 + if (mode == "text" && block) { + indent=length($0) + if (match($0,/[^ ]/)) indent=RSTART-1 + rest=substr($0,indent+1) + blank_line=($0 ~ /^[ \t]*$/) + # A dedented comment ends the scalar. Later indented text cannot resume it + # before a new top-level key supplies another mapping or scalar context. + if ((!blank_line && indent == 0) || + (rest ~ /^#/ && indent < (block_indent ? block_indent : 1))) { + block=0; after_block=1 + } else if (blank_line && rest == "") { + if (!block_indent && indent > block_blank_indent) block_blank_indent=indent + } else { + # Tabs may be scalar content only after an explicit or inferred indentation + # is established; they cannot establish the first line's indentation. + if (!block_indent && rest ~ /^\t/) bad=1 + if (!block_indent) { + block_indent=indent + if (block_blank_indent > indent) bad=1 + } + if (indent < 1 || indent < block_indent) bad=1 + else if (nonblank_text($0)) found=1 + } + } + if (after_block && $0 !~ /^[ \t]*(#.*)?$/ && $0 !~ /^[A-Za-z_][A-Za-z0-9_-]*:/) bad=1 if ($0 ~ /^[[:space:]]*(#.*)?$/) next if ($0 ~ /^[A-Za-z_][A-Za-z0-9_-]*:/) { key=$0; sub(/:.*/,"",key) if (++keys[key] > 1) bad=1 - block=0; in_metadata=(key == "metadata"); depth=0 + block=0; block_indent=0; block_blank_indent=0; after_block=0 + in_metadata=(key == "metadata"); depth=0 raw=substr($0,length(key)+2) if (mode == "text" && key == field) { - v=trim(raw); sub(/[[:space:]]+#.*$/,"",v) - if (v ~ /^[|>][0-9+-]*$/) block=1 + v=yaml_trim(raw); sub(/[ \t]+#.*$/,"",v) + # YAML permits one nonzero indentation digit and one chomping indicator + # in either order. An omitted digit is inferred from the first text line. + if (v ~ /^[|>]([1-9][+-]?|[+-][1-9]?)?$/) { + block=1 + if (match(v,/[1-9]/)) block_indent=substr(v,RSTART,1)+0 + } else { v=scalar(raw); found=scalar_ok } } if (mode == "repository" && in_metadata) { diff --git a/scripts/package-boundaries.test.sh b/scripts/package-boundaries.test.sh index b2a290b8..1cf9ec54 100644 --- a/scripts/package-boundaries.test.sh +++ b/scripts/package-boundaries.test.sh @@ -80,6 +80,87 @@ for scenario in valid number missing outside directory empty invalid-frontmatter expected=reject; case $scenario in valid|empty) expected=pass ;; esac gate "agent-$scenario" "$expected" "$root" done +# Identity text must be usable YAML, rather than merely a nonempty encoded value. +# Each case runs through the complete gate with both supported identity fields. +for field in name description; do + for scenario in plain quoted-colon plain-colon leading-dash leading-question leading-colon \ + leading-percent leading-at leading-backtick leading-bracket leading-brace leading-comma \ + trailing-colon safe-colon safe-dash safe-question safe-leading-colon unicode-colon unicode-dash \ + unicode-question unicode-leading-indicator unicode-trailing-colon valid-strip valid-keep valid-strip-first valid-inferred valid-empty-lines \ + valid-leading-blank valid-explicit-tab-blank valid-explicit-tab-content valid-later-tab \ + valid-explicit-more-indent valid-large-indent block-double-plus block-double-minus block-zero \ + block-double-digit block-plus-minus block-minus-plus block-small-indent block-dedent \ + block-oversized-leading-blank block-leading-tab block-leading-tab-content block-small-tab-indent \ + block-continued-after-comment; do + root="$work/yaml-$field-$scenario"; fixture "$root" + cp "$root/plugins/alpha/plugin.json" "$root/plugins/alpha/.claude-plugin/plugin.json" + mkdir -p "$root/plugins/alpha/agents" + value='' body='' expected=reject + case "$scenario" in + plain) value=sample; expected=pass ;; + quoted-colon) value='"alpha: beta"'; expected=pass ;; + plain-colon) value='alpha: beta' ;; + leading-dash) value='- alpha' ;; + leading-question) value='? alpha' ;; + leading-colon) value=': alpha' ;; + leading-percent) value='%alpha' ;; + leading-at) value='@alpha' ;; + leading-backtick) value='`alpha' ;; + leading-bracket) value=']alpha' ;; + leading-brace) value='}alpha' ;; + leading-comma) value=',alpha' ;; + trailing-colon) value='alpha:' ;; + safe-colon) value='alpha:beta'; expected=pass ;; + safe-dash) value='-alpha'; expected=pass ;; + safe-question) value='?alpha'; expected=pass ;; + safe-leading-colon) value=':alpha'; expected=pass ;; + unicode-colon) value=$'alpha:\302\240beta'; expected=pass ;; + unicode-dash) value=$'-\302\240alpha'; expected=pass ;; + unicode-question) value=$'?\302\240alpha'; expected=pass ;; + unicode-leading-indicator) value=$'\302\240@alpha'; expected=pass ;; + unicode-trailing-colon) value=$'alpha:\302\240'; expected=pass ;; + valid-strip) value='|2-'; body=' alpha'; expected=pass ;; + valid-keep) value='>+2'; body=' alpha'; expected=pass ;; + valid-strip-first) value='|-2'; body=' alpha'; expected=pass ;; + valid-inferred) value='>'; body=$' alpha\n beta'; expected=pass ;; + valid-empty-lines) value='|'; body=$'\n\n alpha\n\n beta'; expected=pass ;; + valid-leading-blank) value='|'; body=$' \n alpha'; expected=pass ;; + valid-explicit-tab-blank) value='|2'; body=$' \t\n alpha'; expected=pass ;; + valid-explicit-tab-content) value='|2'; body=$' \talpha'; expected=pass ;; + valid-later-tab) value='|'; body=$' alpha\n \tbeta'; expected=pass ;; + valid-explicit-more-indent) value='|1'; body=$' alpha\n beta'; expected=pass ;; + valid-large-indent) value='|9'; body=' alpha'; expected=pass ;; + block-double-plus) value='|++'; body=' alpha' ;; + block-double-minus) value='|--'; body=' alpha' ;; + block-zero) value='|0'; body=' alpha' ;; + block-double-digit) value='|99'; body=' alpha' ;; + block-plus-minus) value='|+-'; body=' alpha' ;; + block-minus-plus) value='|-+'; body=' alpha' ;; + block-small-indent) value='|9'; body=' alpha' ;; + block-dedent) value='|'; body=$' alpha\n beta' ;; + block-oversized-leading-blank) value='|'; body=$' \n alpha' ;; + block-leading-tab) value='|'; body=$'\t\n alpha' ;; + block-leading-tab-content) value='|'; body=$' \talpha' ;; + block-small-tab-indent) value='|2'; body=$' \t\n alpha' ;; + block-continued-after-comment) value='|'; body=$' alpha\n# comment\n beta' ;; + esac + header="$root/plugins/alpha/agents/sample.agent.md" + printf '%s\n' --- > "$header" + if [ "$field" = description ]; then printf 'name: sample\n' >> "$header"; fi + printf '%s: %s\n' "$field" "$value" >> "$header" + if [ -n "$body" ]; then printf '%s\n' "$body" >> "$header"; fi + if [ "$field" = name ]; then printf 'description: Example agent.\n' >> "$header"; fi + printf '%s\n' --- >> "$header" + # shellcheck disable=SC2016 # Backticks are literal catalogue markup. + sed 's/`example` |/`example`, `sample` |/' "$root/docs/plugins.md" > "$root/new" + mv "$root/new" "$root/docs/plugins.md" + gate "yaml-$field-$scenario" "$expected" "$root" + if [ "$expected" = reject ] && ! grep -Fq "must declare a non-empty '$field'" "$root/out"; then + printf 'FAIL yaml-%s-%s did not identify the malformed identity\n' "$field" "$scenario" + fail=$((fail+1)) + fi + done +done # The real desired-state fixture must be valid before ambiguity is introduced; # otherwise another schema failure could mask the repeated protected declaration. for scenario in healthy duplicate; do