mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 23:19:09 +02:00
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:
co-authored by
Claude Fable 5
parent
3d97863b14
commit
e0662ea7b5
@@ -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
|
||||
|
||||
@@ -30,3 +30,50 @@ describe("one-way-door credential keyword net (#1839)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("one-way-door credential keyword net (#2024)", () => {
|
||||
const VERBS = ["revoke", "reset", "rotate"];
|
||||
const NOUNS = ["api key", "token", "secret", "credential", "access key", "password"];
|
||||
|
||||
// #2024 repro rows: these leaked as two-way pre-fix because the noun
|
||||
// alternations were mismatched across verbs (revoke lacked secret; reset
|
||||
// lacked secret AND access key). The password-parallel test above passes on
|
||||
// buggy code, so THESE rows are the fails-first proof.
|
||||
test('"reset my secret" / "reset my access key" / "revoke my secret" classify one-way', () => {
|
||||
for (const summary of ["reset my secret", "reset my access key", "revoke my secret"]) {
|
||||
const r = classifyQuestion({ summary });
|
||||
expect(r.oneWay).toBe(true);
|
||||
expect(r.reason).toBe("keyword");
|
||||
}
|
||||
});
|
||||
|
||||
test("full verbs x nouns matrix classifies one-way (singular and plural)", () => {
|
||||
for (const verb of VERBS) {
|
||||
for (const noun of NOUNS) {
|
||||
for (const form of [noun, `${noun}s`]) {
|
||||
const r = classifyQuestion({ summary: `${verb} the production ${form}` });
|
||||
expect(r.oneWay).toBe(true);
|
||||
expect(r.reason).toBe("keyword");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Plural forms leaked before AND after the original #2024 report: \b(...)\b
|
||||
// cannot match "credentials" (no word boundary between the noun and its s).
|
||||
test('plurals: "rotate the credentials" / "revoke all tokens" / "reset the passwords" classify one-way', () => {
|
||||
for (const summary of ["rotate the credentials", "revoke all tokens", "reset the passwords"]) {
|
||||
expect(classifyQuestion({ summary }).oneWay).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("benign summaries stay two-way (no over-match)", () => {
|
||||
for (const summary of [
|
||||
"reset the flaky test runner",
|
||||
"rotate the log files nightly",
|
||||
"revoke the meeting invite",
|
||||
]) {
|
||||
expect(classifyQuestion({ summary }).oneWay).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user