Skip to content

Commit 2136c56

Browse files
sgwannabeclaude
andauthored
fix(security): defense in depth — input-path cap + cache read-path size check (#95 / #83 follow-up) (#96)
* feat(scripts): validate-idea-input.sh — input-path size cap (#95 / #83) Add layer-1 enforcement of the 5000-codepoint idea_summary cap before the seed idea reaches schema validation. The schema's maxLength only fires at S-3 (post-Socratic), well after the idea has already been written to runs/<id>/idea.json, hashed into the cache key, and inflated into the Socratic system prompt. This new validator (cited from commands/new.md + run-supervisor.md) closes the bypass. Wire-up: scripts/pre-flight.sh grows --idea / --idea-file flags so the existing M1 pre-flight pathway gates the size cap as a hard failure (exit 1). Default mode is reject, never silent-truncate. --truncate remains as an opt-in for non-interactive automation only. Refs umbrella #95, deferred from PR #83. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cache): defense-in-depth length check on cmd_get JSON read (#95 / #83) Add a belt-and-suspenders 5000-codepoint check on cached payloads that are idea.spec.json-shaped (top-level dict with a string idea_summary). If the on-disk content exceeds the cap, treat as cache poison and report miss (exit 1) so the caller regenerates against a freshly- validated spec. Non-spec-shaped payloads (e.g. previews.json arrays) are passed through unchanged. Rationale: the schema gate at S-3 is the canonical authority, but a replay path that loads idea.spec.json from disk and skips re-validation (e.g. weak-alias hit + Socratic skip) would bypass it if the file was mutated post-write. This catches that case. Refs umbrella #95, deferred from PR #83. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(security): input-path + read-path cap regression coverage (#95 / #83) Extend tests/fixtures/security/verify-security.sh with three new sections: [#95-L1] validate-idea-input.sh negative (5001), positive (5000), Unicode codepoint counting (Korean), argv vs stdin parity, empty rejection, --truncate mode. [#95-L1-wire] scripts/pre-flight.sh --idea/--idea-file integration — proves the layer-1 helper is actually wired into the pre-flight code path as a hard failure (exit 1), not merely documented. [#95-L3] preview-cache.sh cmd_get poison reject for spec-shaped payloads with idea_summary > 5000, plus boundary accept (5000) and array-payload passthrough. Refs umbrella #95, deferred from PR #83. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(commands): /pf:new cites validate-idea-input enforcement (#95 / #83) Document the layer-1 size-cap gate in two places: - plugins/preview-forge/commands/new.md pre-flight step 8 — cites scripts/pre-flight.sh --idea / scripts/validate-idea-input.sh, documents the bypass policy (NONE — --no-cache does not bypass) and the default-reject UX. - plugins/preview-forge/agents/meta/run-supervisor.md step 11 — M1 Run Supervisor prompt now knows to invoke the validator BEFORE §0.4 idea.json write, §0.8 detect-surface, §0.9 recommend-profile, and any cache-key hashing. Refs umbrella #95, deferred from PR #83. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): apply PR #96 review feedback (gemini) - pre-flight.sh: parse `--idea` regardless of value emptiness so `--idea ""` hard-fails through validate-idea-input.sh's empty-reject branch instead of silently falling through as a legacy invocation (gemini #2 + codex #6 — gate bypass on empty seed). - pre-flight.sh: capture validator stderr in a single execution (`2>&1 >/dev/null` capture) instead of running the validator twice to compute rc + message (gemini #3 — efficiency, halves python3 invocations on the failure path). - validate-idea-input.sh: bound `sys.stdin.read(MAX_LEN+1)` so a multi-megabyte seed cannot inflate Python peak RSS just to count code points (gemini #4). Pass MAX_LEN through argv and drain remaining stdin in chunks to avoid SIGPIPE-on-printf under `set -euo pipefail`. - validate-idea-input.sh: same bounded read + argv pass for the --truncate branch; closes the `$MAX_LEN` shell-interpolation surface in the inline python source (gemini #5 — defense-in-depth on the python -c contract, parallels preview-cache.sh::py_read_json). - run-supervisor.md: replace `<<< "<idea>"` here-string example with the positional-arg form (or `printf '%s' | … -` stdin form), and document explicitly that bash here-strings append a trailing newline which would falsely reject seeds at exactly 5000 code points (gemini #1). Also document `<idea>` / `<id>` / `<ts>` placeholder replacement convention. Refs PR #96 review comments (gemini-code-assist #1-#5, codex #6) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 303c302 commit 2136c56

6 files changed

Lines changed: 539 additions & 2 deletions

File tree

plugins/preview-forge/agents/meta/run-supervisor.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ model: opus
4747
- `action == "hint"`: prompt 생략, `/pf:status` 출력에 "💡 Consider --profile=pro next time"로 정적 힌트만
4848
- `action == "none"`: no-op
4949
10. **Blackboard 초기화**: `runs/r-<ts>/blackboard.db` 생성 + 초기 row: `(run.pre_flight_passed, ts, cwd, cli_ver, profile, surface, escalation_action)`.
50+
11. **Idea-input size cap** (umbrella #95 follow-up, deferred from PR #83 — defense-in-depth layer 1): supervisor MUST run `scripts/validate-idea-input.sh "<idea>"` (positional-arg form; or pipe via `printf '%s' "<idea>" | scripts/validate-idea-input.sh -`; or `scripts/pre-flight.sh --idea "<idea>"`) on the raw seed text **BEFORE** §0.4 (`runs/<id>/idea.json` write), §0.8 (`scripts/detect-surface.sh`), §0.9 (`scripts/recommend-profile.sh`), and any cache-key hashing (§4 weak-key probe / §6 strong-key lookup in `commands/new.md`). Validator exits 0 if `len ≤ 5000` Unicode code points, exits 2 otherwise. On exit 2, abort the run and surface the validator's stderr to the user — never silently truncate. The schema's `idea_summary.maxLength: 5000` is the canonical authority; this gate is belt-and-suspenders so a multi-megabyte seed idea cannot inflate the Socratic system prompt or sha256 keyspace before validation fires. **Do NOT use a bash here-string (`<<< "<idea>"`)** — bash here-strings append a trailing newline and would inflate the count by 1, falsely rejecting seeds at exactly 5000 code points. Note: `<idea>` / `<id>` / `<ts>` placeholders must be replaced with actual runtime values.
5051

51-
CLI에서 `scripts/pre-flight.sh` 또는 `pf check`가 동일 검증을 수동으로 제공. 이 스크립트의 로직을 system prompt 상에서 모방하되, 실제 파일 system 접근은 Bash tool로 수행.
52+
CLI에서 `scripts/pre-flight.sh` 또는 `pf check`가 동일 검증을 수동으로 제공. 이 스크립트의 로직을 system prompt 상에서 모방하되, 실제 파일 system 접근은 Bash tool로 수행. 아이디어 size cap까지 포함해 한 줄로 돌리려면 `scripts/pre-flight.sh --idea "<seed>"`.
5253

5354
### 1. Run 생명주기 관장
5455
- **Post-Socratic escalation re-check** (v1.7.0+ A-2): I1 idea-clarifier가 `runs/<id>/idea.spec.json` Write를 끝낸 **직후**, pre-flight §0.9와 동일한 `scripts/recommend-profile.sh`를 한 번 더 호출한다. 호출 형태 (§0.9과 대칭):

plugins/preview-forge/commands/new.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ M1 Run Supervisor는 **모든 작업 전** 다음을 순서대로 검증합니
5252
5. **api.anthropic.com 연결** — 기본 reachability 확인.
5353
6. **LESSONS pre-load**`~/.claude/preview-forge/memory/LESSONS.md`에서 관련 카테고리(1. PreviewDD, 4. Memory, 6. Plugin 배포)를 읽어 department lead들의 system prompt에 주입.
5454
7. **profile resolve** (v1.3+) — `--profile` 플래그 · env `PF_PROFILE` · `settings.json` defaultProfile 순으로 해결 → `runs/<id>/.profile` 파일에 기록. 이후 모든 hook·monitor가 이 값을 참조.
55+
8. **idea-input size cap** (umbrella #95 follow-up — defense in depth, layer 1) — orchestrator MUST invoke [`scripts/pre-flight.sh --idea "<seed>"`](../../../scripts/pre-flight.sh) (or the equivalent direct call to [`scripts/validate-idea-input.sh`](../../../scripts/validate-idea-input.sh)) on the raw seed idea **BEFORE** writing it to `runs/<id>/idea.json`, computing any cache key (`scripts/preview-cache.sh key …`), or expanding the I1 Socratic interview prompt. The validator exits 0 if `len(idea) ≤ 5000` Unicode code points (matching the `idea_summary` schema cap), exits non-zero otherwise. On non-zero, abort the run with the validator's stderr message — do NOT silently truncate. Rationale: the schema's `idea_summary.maxLength: 5000` only fires at S-3 validation, well after the seed idea has already inflated the Socratic system prompt and been hashed into the cache key. This pre-flight gate stops a 10MB seed idea at the door. Bypass policy: NONE — `--no-cache` does not bypass this check. Truncate mode (`scripts/validate-idea-input.sh --truncate -`) exists for non-interactive automation pipelines that explicitly opt in; `/pf:new` itself MUST default to reject so the user keeps full intent over what gets trimmed. The size cap also runs at S-3 schema validation as the canonical authority — this layer-1 gate is belt-and-suspenders.
5556

56-
CLI 환경에서는 `scripts/pre-flight.sh` 또는 `pf check`로 동일 검증 수동 실행 가능.
57+
CLI 환경에서는 `scripts/pre-flight.sh` 또는 `pf check`로 동일 검증 수동 실행 가능. 아이디어 텍스트까지 포함해 한 번에 검증하려면 `scripts/pre-flight.sh --idea "<seed>"` (or `--idea-file <path>` for inputs that may exceed ARG_MAX).
5758

5859
## 동작 (pre-flight 통과 후)
5960

scripts/pre-flight.sh

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,55 @@ set -euo pipefail
1313
# Resolve paths
1414
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
1515

16+
# Optional idea text — when supplied, run the layer-1 size cap (#95
17+
# follow-up, deferred from PR #83) BEFORE any of the env checks below
18+
# return success. Two argv shapes are accepted so callers (CLI / M1
19+
# Run Supervisor / mock-bootstrap) can wire in cheaply:
20+
#
21+
# scripts/pre-flight.sh --idea "<seed text>"
22+
# scripts/pre-flight.sh --idea-file <path> # reads file, supports >ARG_MAX
23+
#
24+
# Both invoke `scripts/validate-idea-input.sh` with the same exit-code
25+
# contract: 0 on pass, 2 on cap violation. A non-zero rc here surfaces
26+
# as a hard pre-flight failure (`exit 1` in the summary section), which
27+
# matches how the rest of `bad_count` is treated. Empty-string idea
28+
# (e.g. `--idea ""`) is rejected by validate-idea-input.sh's own empty-
29+
# check (parallels preview-cache.sh::cmd_key T-9.1), so callers who
30+
# accidentally pass unset-JSON-field text get a hard fail too.
31+
IDEA_TEXT_FILE=""
32+
IDEA_TEXT_FILE_OWNED=0 # 1 = we created it, must rm on exit
33+
cleanup_idea_tmp() {
34+
if [[ "$IDEA_TEXT_FILE_OWNED" -eq 1 && -n "$IDEA_TEXT_FILE" && -f "$IDEA_TEXT_FILE" ]]; then
35+
rm -f "$IDEA_TEXT_FILE"
36+
fi
37+
}
38+
trap cleanup_idea_tmp EXIT
39+
40+
if [[ "${1:-}" == "--idea" ]]; then
41+
# Parse `--idea` regardless of emptiness — empty seed is exactly the
42+
# case we want to hard-fail at validate-idea-input.sh (T-9.1 parallel),
43+
# NOT silently skip the gate by falling through as a legacy invocation.
44+
if [[ $# -lt 2 ]]; then
45+
echo "pre-flight.sh: --idea requires an argument (use --idea \"\" to test the empty-reject path)" >&2
46+
exit 1
47+
fi
48+
IDEA_TEXT_FILE="$(mktemp -t pf-preflight-idea-XXXXXX)"
49+
IDEA_TEXT_FILE_OWNED=1
50+
printf '%s' "$2" > "$IDEA_TEXT_FILE"
51+
shift 2
52+
elif [[ "${1:-}" == "--idea-file" ]]; then
53+
if [[ $# -lt 2 ]]; then
54+
echo "pre-flight.sh: --idea-file requires a path argument" >&2
55+
exit 1
56+
fi
57+
if [[ ! -f "$2" ]]; then
58+
echo "pre-flight.sh: --idea-file path not found: $2" >&2
59+
exit 1
60+
fi
61+
IDEA_TEXT_FILE="$2"
62+
shift 2
63+
fi
64+
1665
# Walk up from cwd looking for a plugin repo signature
1766
# (`.claude-plugin/marketplace.json` — this is any Claude Code plugin marketplace repo).
1867
# We deliberately don't restrict to "two-weeks-team" since forks, other marketplace
@@ -51,6 +100,32 @@ ok() { echo " ✓ $1"; }
51100
echo "=== Preview Forge pre-flight ==="
52101
echo
53102

103+
# 0. Idea-input size cap (#95 follow-up, deferred from PR #83) — only
104+
# runs when the caller passed `--idea` / `--idea-file`. Skipped silently
105+
# for the legacy invocation `scripts/pre-flight.sh` with no args (env
106+
# check only). When triggered, this is the layer-1 gate that protects
107+
# the rest of the pipeline (idea.json write, cache key hash, Socratic
108+
# prompt expansion) from a >5000-code-point seed idea.
109+
if [[ -n "$IDEA_TEXT_FILE" ]]; then
110+
echo "[0] Idea-input size cap (≤5000 code points)"
111+
if [[ ! -x "$SCRIPT_DIR/validate-idea-input.sh" ]]; then
112+
fail "validate-idea-input.sh missing or not executable at $SCRIPT_DIR/validate-idea-input.sh"
113+
else
114+
# Capture stderr in a single execution (gemini PR #96 review): the
115+
# previous shape ran the validator twice — once for rc, once for the
116+
# message — which is wasteful (and re-streams the entire idea twice
117+
# through python3). `2>&1 >/dev/null` redirects stderr→stdout while
118+
# discarding stdout so $(…) only collects the error message.
119+
if validator_err=$("$SCRIPT_DIR/validate-idea-input.sh" - < "$IDEA_TEXT_FILE" 2>&1 >/dev/null); then
120+
ok "idea text within 5000-code-point cap"
121+
else
122+
validator_rc=$?
123+
fail "idea text rejected by validate-idea-input.sh (rc=$validator_rc): ${validator_err}"
124+
fi
125+
fi
126+
echo
127+
fi
128+
54129
# 1. cwd hygiene
55130
CWD="$(pwd)"
56131
echo "[1/7] Workspace (cwd)"

scripts/preview-cache.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,51 @@ cmd_get() {
249249
return 1
250250
fi
251251

252+
# Defense-in-depth size check (umbrella #95 follow-up, deferred from PR #83).
253+
# If the cached payload is `idea.spec.json`-shaped (has a top-level
254+
# `idea_summary` string field), treat any value > 5000 code points as
255+
# cache poison and report a miss so the caller regenerates against a
256+
# freshly-validated spec.
257+
#
258+
# Why belt-and-suspenders: the schema gate at S-3 already rejects
259+
# oversized `idea_summary`. But a cache replay path that reads
260+
# `idea.spec.json` from disk and short-circuits validation (e.g. weak-
261+
# alias hit + Socratic skip) would bypass that gate entirely if the
262+
# on-disk file was mutated after the original write. A length check
263+
# here closes that bypass.
264+
#
265+
# Why "treat as miss" not "fail loudly": cache reads are non-
266+
# authoritative by design (TTL expiry, Socratic spec change → both
267+
# already cause a benign miss). Returning 1 here lets the caller
268+
# regenerate, mirroring the existing TTL-expiry path. No data loss —
269+
# just a forced re-validate. This matches the existing W1-W4 cache
270+
# safety posture (get-fallback exit 2, etc).
271+
#
272+
# Why python3 (not jq / shell parsing): zero-third-party-dep policy
273+
# (LESSON 0.4); python3 is already a hard dep here (see py_read_json
274+
# / py_file_age helpers above). The script handles the not-spec-shaped
275+
# case (e.g. a raw `previews.json` array) by simply skipping the check
276+
# — only spec-shaped payloads with a string `idea_summary` are gated.
277+
local oversize_check
278+
oversize_check=$(python3 -c "
279+
import json, sys
280+
try:
281+
d = json.load(open(sys.argv[1], encoding='utf-8'))
282+
except Exception:
283+
print('skip')
284+
sys.exit(0)
285+
if isinstance(d, dict):
286+
summary = d.get('idea_summary')
287+
if isinstance(summary, str) and len(summary) > 5000:
288+
print('poison')
289+
sys.exit(0)
290+
print('ok')
291+
" "$file" 2>/dev/null || echo "skip")
292+
if [[ "$oversize_check" == "poison" ]]; then
293+
echo "preview-cache.sh: cached payload at '$file' has idea_summary > 5000 chars — treating as poisoned cache, reporting miss" >&2
294+
return 1
295+
fi
296+
252297
cat "$file"
253298
}
254299

scripts/validate-idea-input.sh

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env bash
2+
# Preview Forge — Layer-1 input-path size cap for /pf:new.
3+
#
4+
# WHY (umbrella #95 follow-up, deferred from PR #83)
5+
# ---------------------------------------------------
6+
# `plugins/preview-forge/schemas/idea-spec.schema.json` caps `idea_summary`
7+
# at 5000 chars. That cap fires at the **S-3 schema validation layer**,
8+
# i.e. AFTER the seed idea has already been:
9+
# - copied into runs/<id>/idea.json
10+
# - inflated into the I1 Socratic interview prompt (system prompt + 3
11+
# AskUserQuestion modals)
12+
# - keyed through `scripts/preview-cache.sh key` (which itself hashes
13+
# the raw idea string into the cache key — a 10MB idea would happily
14+
# stream through sha256)
15+
#
16+
# A 10MB seed idea today would walk through all of that BEFORE the
17+
# schema layer rejects it. This script is the layer-1 gate cited from
18+
# `plugins/preview-forge/commands/new.md`: callers (CLI helper or
19+
# orchestrator) invoke it with the raw seed text and reject early if it
20+
# exceeds the 5000-char cap, mirroring the schema's `maxLength`.
21+
#
22+
# DEFAULT IS REJECT, not silent-truncate
23+
# --------------------------------------
24+
# Truncation would silently lose user intent — half the idea disappears
25+
# and the Socratic interview proceeds against a corrupted seed. Explicit
26+
# reject + a clear error message lets the user decide whether to trim.
27+
# `--truncate` is provided for callers that opt in (e.g. an automation
28+
# pipeline that re-emits the trimmed payload back to a file).
29+
#
30+
# USAGE
31+
# validate-idea-input.sh "<idea text>" # argv form
32+
# validate-idea-input.sh - < idea.txt # stdin form (- sentinel,
33+
# # parallels preview-cache.sh
34+
# # T-9.3 convention)
35+
# validate-idea-input.sh --truncate "<idea text>" # emit first 5000 chars
36+
# validate-idea-input.sh --truncate - # truncate from stdin
37+
#
38+
# EXIT CODES
39+
# 0 → length ≤ 5000 chars; (default mode) idea echoed to stdout
40+
# unchanged; (truncate mode) idea echoed
41+
# unchanged
42+
# 2 → length > 5000 chars; (default mode) reject with stderr message;
43+
# (truncate mode) first 5000 chars echoed,
44+
# warning to stderr, exit 0 (NOT 2)
45+
# 64 → usage error
46+
#
47+
# CHARACTER vs BYTE COUNTING
48+
# --------------------------
49+
# The schema's `maxLength` is JSON Schema's `maxLength` keyword, which
50+
# per the spec counts **Unicode code points** (not UTF-8 bytes). We use
51+
# python3's `len(str)` which is exactly that — keeping this gate aligned
52+
# with the schema gate so a Korean idea that passes here doesn't get
53+
# rejected at S-3 (or vice versa). Zero-third-party-dep policy preserved
54+
# (LESSON 0.4): python3 is already a hard dependency for the rest of
55+
# the plugin (preview-cache helpers, hooks).
56+
57+
set -euo pipefail
58+
59+
MAX_LEN=5000
60+
mode="reject"
61+
62+
usage() {
63+
cat >&2 <<'EOF'
64+
usage: validate-idea-input.sh [--truncate] {<idea-text> | -}
65+
- reads idea text from argv (one positional arg) or stdin (when arg is `-`)
66+
- default mode: exit 2 + stderr message if len > 5000 code points
67+
- --truncate: emit first 5000 code points + stderr warn, exit 0
68+
EOF
69+
exit 64
70+
}
71+
72+
if [[ $# -lt 1 ]]; then
73+
usage
74+
fi
75+
76+
if [[ "$1" == "--truncate" ]]; then
77+
mode="truncate"
78+
shift
79+
fi
80+
81+
if [[ $# -ne 1 ]]; then
82+
usage
83+
fi
84+
85+
idea_arg="$1"
86+
87+
# Read the idea text. Mirror the `-` sentinel convention used by
88+
# scripts/preview-cache.sh::cmd_key (see T-9.3 rationale): callers that
89+
# may exceed ARG_MAX (macOS ~256KB, some hosts smaller) pipe via stdin.
90+
if [[ "$idea_arg" == "-" ]]; then
91+
# Use the same "append _ then strip exactly one" trick as preview-cache
92+
# to preserve trailing newlines through bash command substitution.
93+
idea=$(cat; echo _)
94+
idea="${idea%_}"
95+
else
96+
idea="$idea_arg"
97+
fi
98+
99+
# Empty input is a hard reject (parallels preview-cache.sh T-9.1: an
100+
# empty seed idea cannot be a legitimate Socratic input either, and
101+
# silently passing "" would make the rest of the pipeline misbehave).
102+
if [[ -z "$idea" ]]; then
103+
echo "validate-idea-input.sh: idea text is empty — refusing" >&2
104+
exit 2
105+
fi
106+
107+
# Length check via python3 (Unicode code points, matching JSON Schema
108+
# `maxLength` semantics — see header). Argv pass + single-quoted heredoc
109+
# closes the inline-string interpolation surface (same pattern as
110+
# scripts/preview-cache.sh::py_read_json caller contract).
111+
#
112+
# Bounded read (gemini PR #96 review): we only need to know whether the
113+
# length exceeds MAX_LEN, so cap stdin.read at MAX_LEN+1 code points to
114+
# avoid pulling a multi-megabyte payload into Python memory. If the read
115+
# returns exactly MAX_LEN+1 chars, we know length > MAX_LEN. We pass
116+
# MAX_LEN as argv to avoid shell-interpolating it into the inline python
117+
# source (parallels preview-cache.sh::py_read_json contract).
118+
#
119+
# Note: python MUST drain the rest of stdin even after the bounded read,
120+
# otherwise `printf '%s' "$idea"` upstream gets SIGPIPE when python
121+
# exits early — and `set -euo pipefail` propagates that as rc=141 to the
122+
# overall pipeline. Cheap drain: a no-op .read(1<<20) loop. Cost is the
123+
# same as the unbounded form but bounded *peak* memory by chunking, so
124+
# we still meet the gemini review intent (peak RSS, not total throughput).
125+
length=$(printf '%s' "$idea" | python3 -c '
126+
import sys
127+
limit = int(sys.argv[1])
128+
data = sys.stdin.read(limit + 1)
129+
n = len(data)
130+
# Drain remaining bytes in chunks so upstream printf does not SIGPIPE
131+
# under pipefail. Chunk size keeps peak RSS bounded.
132+
while sys.stdin.read(1 << 20):
133+
pass
134+
print(n)
135+
' "$MAX_LEN")
136+
137+
if [[ "$length" -le "$MAX_LEN" ]]; then
138+
# Pass-through: emit the idea on stdout for the caller to capture
139+
# (truncate mode emits same content).
140+
printf '%s' "$idea"
141+
exit 0
142+
fi
143+
144+
# Over the cap.
145+
if [[ "$mode" == "truncate" ]]; then
146+
echo "validate-idea-input.sh: idea length>${MAX_LEN} — truncating to first $MAX_LEN code points" >&2
147+
# Bounded read + argv pass (gemini PR #96 review): only read MAX_LEN
148+
# code points (we throw away anything beyond), and pass MAX_LEN through
149+
# argv so the python source itself stays single-quoted — no shell
150+
# interpolation surface.
151+
printf '%s' "$idea" | python3 -c '
152+
import sys
153+
limit = int(sys.argv[1])
154+
sys.stdout.write(sys.stdin.read(limit))
155+
# Drain to avoid upstream printf SIGPIPE under pipefail (see length-
156+
# check rationale above).
157+
while sys.stdin.read(1 << 20):
158+
pass
159+
' "$MAX_LEN"
160+
exit 0
161+
fi
162+
163+
# Default mode: hard reject. Note: `length` is bounded at MAX_LEN+1 by
164+
# the read cap above (gemini PR #96 review — peak RSS protection), so
165+
# we report ">${MAX_LEN}" instead of the exact overflow count.
166+
cat >&2 <<EOF
167+
validate-idea-input.sh: idea length>${MAX_LEN} (exact: ≥${length}) exceeds $MAX_LEN-character cap.
168+
169+
The /pf:new seed idea is bounded at $MAX_LEN Unicode code points to match
170+
the idea-spec schema's idea_summary maxLength. Please shorten the idea, or
171+
re-invoke with --truncate to silently trim to the first $MAX_LEN chars.
172+
EOF
173+
exit 2

0 commit comments

Comments
 (0)