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
+41
View File
@@ -298,6 +298,47 @@ describe('enforces never-ask preferences', () => {
});
expectPassThrough(r);
});
// #2024: unregistered ids used to default straight to two-way without ever
// consulting the keyword classifier — an ad-hoc DESTRUCTIVE question with a
// stored never-ask preference auto-decided. The hook now falls back to
// classifyQuestion on the question text when the registry lookup misses.
test('unregistered id + never-ask + destructive text → pass-through (keyword net fires, #2024)', () => {
writeProjectPref('adhoc-credential-cleanup', 'never-ask');
const r = runHook({
session_id: 's-kw-1',
tool_name: 'AskUserQuestion',
tool_use_id: 'tu-kw-1',
tool_input: {
questions: [
{
question: '<gstack-qid:adhoc-credential-cleanup> Reset my secret and proceed?',
options: ['A) Yes (recommended)', 'B) No'],
},
],
},
});
expectPassThrough(r);
});
test('unregistered id + never-ask + benign text → still deny (auto-decide unchanged)', () => {
writeProjectPref('adhoc-credential-cleanup', 'never-ask');
const r = runHook({
session_id: 's-kw-2',
tool_name: 'AskUserQuestion',
tool_use_id: 'tu-kw-2',
tool_input: {
questions: [
{
question: '<gstack-qid:adhoc-credential-cleanup> Reorganize the TODOs file?',
options: ['A) Yes (recommended)', 'B) No'],
},
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide');
});
});
// ----------------------------------------------------------------------