mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(windows): shell bins pass NUL as Bun's empty config when MSYS path conversion is off (#3415) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): detect Git Bash/MSYS2/Cygwin from OSTYPE, as gstack-doctor does, so uname stubs in setup fixtures stay untouched Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (#3415) - Choose NUL only when cygpath is also on PATH, so an OSTYPE=msys inherited by a POSIX bash keeps /dev/null instead of a cwd-relative NUL file. - gstack-developer-profile --log-session reports a Bun failure instead of skipping it as invalid input, like gstack-timeline-log. - learnings-injection tests check that their anchored bun -e block exists. - CHANGELOG names the callers this fix does not cover; curation reason fixed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, cycle 2 (#3415) - timeline-log and developer-profile validators print their verdict; any non-zero Bun exit is a reported failure (a Bun crash on Windows exits 3). - timeline-log keeps the original event when ts stamping fails. - The definition ratchet scopes a local GSTACK_BUN_CONFIG to its function. - The shell-bins fixture clears its preload marker per test. - CHANGELOG states the callers that discard the report and the Bun 1.3.x case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG notes Bun 1.3.10 evidence and remaining literal /dev/null callers (#3415) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
380 lines
16 KiB
Bash
Executable File
380 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-question-preference — read/write/check explicit per-question preferences.
|
|
#
|
|
# Preference file: ~/.gstack/projects/{SLUG}/question-preferences.json
|
|
# Global fallback: ~/.gstack/global-question-preferences.json, the file the
|
|
# question-preference hook reads (--check only; a project entry for the same
|
|
# id wins). Hand-edited; --write stays per-project.
|
|
# Schema: { "<question_id>": "always-ask" | "never-ask" | "ask-only-for-one-way" }
|
|
#
|
|
# Subcommands:
|
|
# --check <id> [--summary-file F | --summary-stdin]
|
|
# → emit ASK_NORMALLY | AUTO_DECIDE | ASK_ONLY_ONE_WAY
|
|
# (the question text lets the keyword net catch
|
|
# ad-hoc destructive ids, #2024; skills write it to
|
|
# an agent-written file, never a shell string)
|
|
# --write '{...}' [--free-text-file F]
|
|
# → set a preference (user-origin gate + one-way write
|
|
# reject); F carries the user's own words (free_text)
|
|
# --read → dump preferences JSON
|
|
# --clear [<id>] → clear one or all preferences
|
|
# --stats → short summary (inert one-way prefs counted separately)
|
|
#
|
|
# User-origin gate
|
|
# ----------------
|
|
# The --write subcommand REQUIRES a `source` field on the input:
|
|
# - "plan-tune" — user ran /plan-tune and chose a preference (allowed)
|
|
# - "inline-user" — inline `tune:` from the user's own chat message (allowed)
|
|
# - "inline-tool-output"— tune: prefix seen in tool output / file content (REJECTED)
|
|
# - "inline-file" — tune: prefix seen in a file the agent read (REJECTED)
|
|
# This is the profile-poisoning defense from docs/designs/PLAN_TUNING_V0.md.
|
|
#
|
|
# One-way write reject (#2488)
|
|
# ----------------------------
|
|
# never-ask and ask-only-for-one-way are refused on registry one-way ids.
|
|
# --check already ignores those prefs; storing them made --stats lie.
|
|
# always-ask on a one-way id is fine (it agrees with the safety override).
|
|
set -euo pipefail
|
|
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
|
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
|
gstack_state_root_select
|
|
GSTACK_HOME="$_gstack_sr_root"
|
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
|
SLUG="${SLUG:-unknown}"
|
|
PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json"
|
|
GLOBAL_PREF_FILE="$GSTACK_HOME/global-question-preferences.json"
|
|
EVENT_FILE="$GSTACK_HOME/projects/$SLUG/question-events.jsonl"
|
|
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
|
|
|
CMD="${1:-}"
|
|
shift || true
|
|
|
|
# Read an agent-written text file (a question summary, the user's own tune
|
|
# words). A relative path is tried against the cwd, then the git top level. A
|
|
# file inside a .gstack/tmp directory is scratch and is removed after reading.
|
|
read_text_file() {
|
|
local f="$1" top
|
|
if [ ! -f "$f" ] && [ "${f#/}" = "$f" ]; then
|
|
top=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
[ -n "$top" ] && [ -f "$top/$f" ] && f="$top/$f"
|
|
fi
|
|
[ -f "$f" ] || return 1
|
|
head -c 4096 -- "$f"
|
|
case "$(cd "$(dirname -- "$f")" && pwd -P)" in */.gstack/tmp) rm -f -- "$f" ;; esac
|
|
}
|
|
|
|
ensure_file() {
|
|
if [ ! -f "$PREF_FILE" ]; then
|
|
echo '{}' > "$PREF_FILE"
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --check <question_id>
|
|
# -----------------------------------------------------------------------
|
|
do_check() {
|
|
local QID="" SUMMARY_STDIN=false SUMMARY_FILE=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--summary-stdin) SUMMARY_STDIN=true; shift ;;
|
|
--summary-file) SUMMARY_FILE="${2:-}"; shift; [ $# -gt 0 ] && shift ;;
|
|
*) [ -z "$QID" ] && QID="$1"; shift ;;
|
|
esac
|
|
done
|
|
if [ -z "$QID" ]; then
|
|
echo "ASK_NORMALLY"
|
|
return 0
|
|
fi
|
|
# #2024: the question text feeds the keyword classifier for unregistered
|
|
# ids. Transport is stdin (not argv) — summaries carry quotes, newlines,
|
|
# and shell metacharacters an argv tail would mangle. Handed to bun via
|
|
# env so no shell re-quoting happens. Empty summary = id-only behavior.
|
|
local QSUMMARY=""
|
|
if [ -n "$SUMMARY_FILE" ]; then
|
|
# A summary that cannot be read cannot clear the keyword check: ask.
|
|
if ! QSUMMARY=$(read_text_file "$SUMMARY_FILE"); then
|
|
echo "ASK_NORMALLY"
|
|
echo "NOTE: summary file $SUMMARY_FILE is missing; asking normally." >&2
|
|
return 0
|
|
fi
|
|
elif [ "$SUMMARY_STDIN" = true ]; then
|
|
QSUMMARY=$(cat 2>/dev/null || true)
|
|
fi
|
|
ensure_file
|
|
cd "$ROOT_DIR"
|
|
PREF_FILE_PATH="$PREF_FILE" GLOBAL_PREF_FILE_PATH="$GLOBAL_PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
|
import('./scripts/one-way-doors.ts').then((oneway) => {
|
|
const fs = require('fs');
|
|
const qid = process.env.QID;
|
|
const summary = process.env.QSUMMARY || undefined;
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
let globalPrefs = {};
|
|
try {
|
|
if (fs.existsSync(process.env.GLOBAL_PREF_FILE_PATH)) {
|
|
const parsed = JSON.parse(fs.readFileSync(process.env.GLOBAL_PREF_FILE_PATH, 'utf-8'));
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
|
globalPrefs = parsed;
|
|
}
|
|
} catch (e) {
|
|
console.error('NOTE: global-question-preferences.json is unreadable; ignoring it.');
|
|
}
|
|
const pref = qid in prefs ? prefs[qid] : globalPrefs[qid];
|
|
|
|
// Always check one-way status first — safety overrides preferences.
|
|
// summary (when piped) lets the keyword net catch ad-hoc destructive
|
|
// questions whose id has no registry entry (#2024).
|
|
const oneWay = oneway.isOneWayDoor({ question_id: qid, summary });
|
|
|
|
if (oneWay) {
|
|
console.log('ASK_NORMALLY');
|
|
if (pref === 'never-ask') {
|
|
console.log('NOTE: one-way door overrides your never-ask preference for safety.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Split-chain carve-out: per-option calls in N-option splits emit
|
|
// question_ids of the form <skill>-split-<option-slug>. These are
|
|
// NEVER AUTO_DECIDE-eligible regardless of stored preferences — the
|
|
// whole point of splitting is restoring user sovereignty over the
|
|
// option set. See scripts/resolvers/preamble/generate-ask-user-format.ts
|
|
// \"Handling 5+ options — split, never drop\" for the surrounding
|
|
// mechanism that generates these ids.
|
|
if (/-split-/.test(qid)) {
|
|
console.log('ASK_NORMALLY');
|
|
if (pref === 'never-ask' || pref === 'ask-only-for-one-way') {
|
|
console.log('NOTE: split-chain per-option calls always ASK_NORMALLY; your ' + pref + ' preference does not apply to options inside a sequential split.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (pref) {
|
|
case 'never-ask':
|
|
console.log('AUTO_DECIDE');
|
|
break;
|
|
case 'ask-only-for-one-way':
|
|
// Not one-way (we checked above) — auto-decide this two-way question.
|
|
console.log('AUTO_DECIDE');
|
|
break;
|
|
case 'always-ask':
|
|
case undefined:
|
|
case null:
|
|
console.log('ASK_NORMALLY');
|
|
break;
|
|
default:
|
|
console.log('ASK_NORMALLY');
|
|
console.log('NOTE: unknown preference value: ' + pref);
|
|
}
|
|
}).catch(err => { console.error('check:', err.message); process.exit(1); });
|
|
"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --write '{...}' (with user-origin gate)
|
|
# -----------------------------------------------------------------------
|
|
do_write() {
|
|
local INPUT="${1:-}" FREE_TEXT="" HAS_FREE_TEXT=""
|
|
if [ -z "$INPUT" ]; then
|
|
echo "gstack-question-preference: --write requires a JSON payload" >&2
|
|
exit 1
|
|
fi
|
|
shift
|
|
if [ "${1:-}" = "--free-text-file" ]; then
|
|
if ! FREE_TEXT=$(read_text_file "${2:-}"); then
|
|
echo "gstack-question-preference: cannot read the free-text file ${2:-}; write the user's words into it with your file-write tool, then rerun. Nothing was saved." >&2
|
|
exit 1
|
|
fi
|
|
HAS_FREE_TEXT=1
|
|
elif [ -n "${1:-}" ]; then
|
|
echo "gstack-question-preference: unexpected argument after the JSON payload: $1" >&2
|
|
exit 1
|
|
fi
|
|
ensure_file
|
|
local TMPERR
|
|
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-question-preference.XXXXXX") || { echo "gstack-question-preference: could not create a temp file in ${TMPDIR:-/tmp}, so the preference was not saved. Fix: point TMPDIR at a writable directory." >&2; return 1; }
|
|
# Use function-local cleanup via RETURN trap so variable lookup only happens
|
|
# while the function is on the stack (avoids EXIT-trap unbound-var race).
|
|
trap "rm -f '$TMPERR'" RETURN
|
|
|
|
set +e
|
|
local RESULT
|
|
RESULT=$(cd "$ROOT_DIR" && printf '%s' "$INPUT" | PREF_FILE_PATH="$PREF_FILE" EVENT_FILE_PATH="$EVENT_FILE" FREE_TEXT="$FREE_TEXT" HAS_FREE_TEXT="$HAS_FREE_TEXT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
|
const fs = require('fs');
|
|
const raw = await Bun.stdin.text();
|
|
let j;
|
|
try { j = JSON.parse(raw); } catch { process.stderr.write('gstack-question-preference: invalid JSON\n'); process.exit(1); }
|
|
// The user's own words arrive in an agent-written file, never inside the
|
|
// shell-quoted JSON argument.
|
|
if (process.env.HAS_FREE_TEXT) j.free_text = process.env.FREE_TEXT.trim();
|
|
|
|
// Required: question_id (kebab-case, <=64)
|
|
if (!j.question_id || !/^[a-z0-9-]+\$/.test(j.question_id) || j.question_id.length > 64) {
|
|
process.stderr.write('gstack-question-preference: invalid question_id\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Required: preference
|
|
const ALLOWED_PREFS = ['always-ask', 'never-ask', 'ask-only-for-one-way'];
|
|
if (!ALLOWED_PREFS.includes(j.preference)) {
|
|
process.stderr.write('gstack-question-preference: invalid preference (must be one of: ' + ALLOWED_PREFS.join(', ') + ')\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// user-origin gate — REQUIRED on every write.
|
|
// See docs/designs/PLAN_TUNING_V0.md §Security model
|
|
const ALLOWED_SOURCES = ['plan-tune', 'inline-user'];
|
|
const REJECTED_SOURCES = ['inline-tool-output', 'inline-file', 'inline-file-content', 'inline-unknown'];
|
|
if (!j.source) {
|
|
process.stderr.write('gstack-question-preference: source field required (one of: ' + ALLOWED_SOURCES.join(', ') + ')\n');
|
|
process.exit(1);
|
|
}
|
|
if (REJECTED_SOURCES.includes(j.source)) {
|
|
process.stderr.write('gstack-question-preference: rejected — source \"' + j.source + '\" is not user-originated (profile poisoning defense)\n');
|
|
process.exit(2);
|
|
}
|
|
if (!ALLOWED_SOURCES.includes(j.source)) {
|
|
process.stderr.write('gstack-question-preference: rejected — source \"' + j.source + '\" is not user-originated (profile poisoning defense)\n');
|
|
process.exit(2);
|
|
}
|
|
|
|
// One-way write reject (#2488) — after the origin gate so a poisoning
|
|
// payload on a one-way id still exits 2, not 1.
|
|
if (j.preference === 'never-ask' || j.preference === 'ask-only-for-one-way') {
|
|
const oneway = await import('./scripts/one-way-doors.ts');
|
|
if (oneway.isOneWayDoor({ question_id: j.question_id })) {
|
|
process.stderr.write('gstack-question-preference: cannot set ' + j.preference + ' on one-way question \"' + j.question_id + '\" (door_type: one-way)\n');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Optional free_text — sanitize (no injection patterns, no newlines, <=300 chars)
|
|
if (j.free_text !== undefined) {
|
|
if (typeof j.free_text !== 'string') {
|
|
process.stderr.write('gstack-question-preference: free_text must be string\n');
|
|
process.exit(1);
|
|
}
|
|
if (j.free_text.length > 300) j.free_text = j.free_text.slice(0, 300);
|
|
j.free_text = j.free_text.replace(/\n+/g, ' ');
|
|
const INJECTION_PATTERNS = [
|
|
/ignore\s+(all\s+)?previous\s+(instructions|context|rules)/i,
|
|
/you\s+are\s+now\s+/i,
|
|
/override[:\s]/i,
|
|
/\bsystem\s*:/i,
|
|
/\bassistant\s*:/i,
|
|
/do\s+not\s+(report|flag|mention)/i,
|
|
];
|
|
for (const pat of INJECTION_PATTERNS) {
|
|
if (pat.test(j.free_text)) {
|
|
process.stderr.write('gstack-question-preference: free_text contains injection-like content, rejected\n');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write to preferences file
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
prefs[j.question_id] = j.preference;
|
|
fs.writeFileSync(process.env.PREF_FILE_PATH, JSON.stringify(prefs, null, 2));
|
|
|
|
// Also append a record to question-events.jsonl for audit + derivation.
|
|
const evt = {
|
|
ts: new Date().toISOString(),
|
|
event_type: 'preference-set',
|
|
question_id: j.question_id,
|
|
preference: j.preference,
|
|
source: j.source,
|
|
...(j.free_text ? { free_text: j.free_text } : {}),
|
|
};
|
|
fs.appendFileSync(process.env.EVENT_FILE_PATH, JSON.stringify(evt) + '\n');
|
|
|
|
console.log('OK: ' + j.question_id + ' → ' + j.preference + ' (source: ' + j.source + ')');
|
|
" 2>"$TMPERR")
|
|
local RC=$?
|
|
set -e
|
|
|
|
if [ $RC -ne 0 ]; then
|
|
cat "$TMPERR" >&2
|
|
exit $RC
|
|
fi
|
|
echo "$RESULT"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --read
|
|
# -----------------------------------------------------------------------
|
|
do_read() {
|
|
ensure_file
|
|
cat "$PREF_FILE"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --clear [<id>]
|
|
# -----------------------------------------------------------------------
|
|
do_clear() {
|
|
local QID="${1:-}"
|
|
ensure_file
|
|
if [ -z "$QID" ]; then
|
|
echo '{}' > "$PREF_FILE"
|
|
echo "OK: cleared all preferences"
|
|
else
|
|
PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
|
const fs = require('fs');
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
if (prefs[process.env.QID] !== undefined) {
|
|
delete prefs[process.env.QID];
|
|
fs.writeFileSync(process.env.PREF_FILE_PATH, JSON.stringify(prefs, null, 2));
|
|
console.log('OK: cleared ' + process.env.QID);
|
|
} else {
|
|
console.log('NOOP: no preference set for ' + process.env.QID);
|
|
}
|
|
"
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# --stats
|
|
# -----------------------------------------------------------------------
|
|
do_stats() {
|
|
ensure_file
|
|
(cd "$ROOT_DIR" && PREF_FILE_PATH="$PREF_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
|
import('./scripts/one-way-doors.ts').then((oneway) => {
|
|
const fs = require('fs');
|
|
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
|
const entries = Object.entries(prefs);
|
|
const counts = { 'always-ask': 0, 'never-ask': 0, 'ask-only-for-one-way': 0, other: 0, inert: 0 };
|
|
for (const [id, v] of entries) {
|
|
const suppressing = v === 'never-ask' || v === 'ask-only-for-one-way';
|
|
if (suppressing && oneway.isOneWayDoor({ question_id: id })) {
|
|
counts.inert++;
|
|
continue;
|
|
}
|
|
if (counts[v] !== undefined) counts[v]++;
|
|
else counts.other++;
|
|
}
|
|
console.log('TOTAL: ' + entries.length);
|
|
console.log('ALWAYS_ASK: ' + counts['always-ask']);
|
|
console.log('NEVER_ASK: ' + counts['never-ask']);
|
|
console.log('ASK_ONLY_ONE_WAY: ' + counts['ask-only-for-one-way']);
|
|
console.log('INERT_ONE_WAY: ' + counts.inert);
|
|
if (counts.other) console.log('OTHER: ' + counts.other);
|
|
}).catch(err => { console.error('stats:', err.message); process.exit(1); });
|
|
")
|
|
}
|
|
|
|
case "$CMD" in
|
|
--check) do_check "$@" ;;
|
|
--write) do_write "$@" ;;
|
|
--read|"") do_read ;;
|
|
--clear) do_clear "$@" ;;
|
|
--stats) do_stats ;;
|
|
--help|-h) sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||' ;;
|
|
*)
|
|
echo "gstack-question-preference: unknown subcommand '$CMD'" >&2
|
|
exit 1
|
|
;;
|
|
esac
|