fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024)

Library fix: revoke/reset/rotate now share ONE noun alternation (api key,
token, secret, credential, access key, password) with optional plural s?.
Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret"
(mismatched per-verb lists) and every plural form ("rotate the credentials",
"revoke all tokens" — \b(...)\b cannot match a trailing s).

Runtime wiring — the regexes could never fire in production before:
- gstack-question-preference --check gains --summary-stdin: the question
  text pipes via stdin (never argv — summaries carry quotes/newlines/shell
  metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc
  destructive question with a stored never-ask preference now forces
  ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics.
- question-preference-hook falls back to classifyQuestion(question text)
  when the registry lookup misses, so unregistered destructive questions
  pass through to a human instead of auto-deciding.
- question-tuning resolver prose shows the piped form (SKILL.md regen lands
  in the wave's release commit).

Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix
with the #2024 repro rows, benign-summary no-over-match rows, stdin
transport survival (quotes/newlines), empty-stdin fail-safe, and hook
fallback both directions (destructive -> pass-through, benign -> deny).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-09 19:13:54 -07:00
co-authored by Claude Fable 5
parent 3d97863b14
commit e0662ea7b5
7 changed files with 199 additions and 11 deletions
+57
View File
@@ -39,6 +39,20 @@ function run(...args: string[]): { stdout: string; stderr: string; status: numbe
};
}
function runWithStdin(input: string, ...args: string[]): { stdout: string; stderr: string; status: number } {
const res = spawnSync(BIN, args, {
env: { ...process.env, GSTACK_HOME: tmpHome },
encoding: 'utf-8',
cwd: ROOT,
input,
});
return {
stdout: res.stdout ?? '',
stderr: res.stderr ?? '',
status: res.status ?? -1,
};
}
// -----------------------------------------------------------------------
// --check
// -----------------------------------------------------------------------
@@ -103,6 +117,49 @@ describe('--check with preferences set', () => {
});
});
// #2024: the keyword net only fires when the question TEXT reaches the
// classifier. --summary-stdin pipes it (stdin, not argv — summaries carry
// quotes/newlines/shell metacharacters). Without the summary, an unregistered
// id with never-ask auto-decides even for destructive phrasings.
describe('--check --summary-stdin (#2024 keyword net plumb-through)', () => {
function setPref(id: string, pref: string) {
return run('--write', JSON.stringify({ question_id: id, preference: pref, source: 'plan-tune' }));
}
test('destructive summary on unregistered never-ask id → ASK_NORMALLY (keyword net fires)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = runWithStdin('Should I reset my secrets now?', '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.status).toBe(0);
expect(r.stdout).toContain('ASK_NORMALLY');
expect(r.stdout).toContain('one-way door overrides');
});
test('same id WITHOUT summary still AUTO_DECIDEs (id-only fallback, current semantics)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = run('--check', 'adhoc-cleanup-question');
expect(r.stdout.trim()).toContain('AUTO_DECIDE');
});
test('benign summary on unregistered never-ask id → AUTO_DECIDE (no over-match)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = runWithStdin('Reorganize the TODOs file?', '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.stdout.trim()).toContain('AUTO_DECIDE');
});
test('summary with quotes/newlines/dashes survives the stdin transport', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const summary = 'Run "cleanup" --now\nthen rotate the access keys?';
const r = runWithStdin(summary, '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.stdout).toContain('ASK_NORMALLY');
});
test('empty stdin with --summary-stdin → id-only behavior (fail-safe)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = runWithStdin('', '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.stdout.trim()).toContain('AUTO_DECIDE');
});
});
// Split-chain carve-out: question_ids matching <skill>-split-<option-slug>
// must always ASK_NORMALLY regardless of stored preferences.
// See scripts/resolvers/preamble/generate-ask-user-format.ts