one-way-doors.ts
166 lines · scripts/one-way-doors.ts at 3f79d81
1/**
2 * One-Way Door Classifier — belt-and-suspenders safety layer.
3 *
4 * Primary safety gate is the `door_type` field in scripts/question-registry.ts.
5 * Every registered AskUserQuestion declares whether it is one-way (always ask,
6 * never auto-decide) or two-way (can be suppressed by explicit user preference).
7 *
8 * This file is a SECONDARY keyword-pattern check for questions that fire
9 * WITHOUT a registry id (ad-hoc question_ids generated at runtime). If the
10 * question_summary contains any of the destructive keyword patterns, treat
11 * it as one-way regardless of what the (absent or unknown) registry entry says.
12 *
13 * Codex correctly pointed out (design doc Decision C) that prose-parsing is
14 * too weak to be the PRIMARY safety gate — wording can change. The registry
15 * is primary. This is the fallback for questions not yet catalogued, and it
16 * errs on the side of asking the user even when tuning preferences say skip.
17 *
18 * Ordering
19 * --------
20 * isOneWayDoor() is called by gstack-question-sensitivity --check in this
21 * order:
22 * 1. Look up registry by id → use registry.door_type if found
23 * 2. If not in registry: apply keyword patterns below
24 * 3. Default to ASK_NORMALLY (safer than AUTO_DECIDE)
25 */
27import { getQuestion } from './question-registry';
29/**
30 * Keyword patterns that identify one-way-door questions when the registry
31 * doesn't have an entry for the question_id. Case-insensitive substring match
32 * against the question_summary passed into AskUserQuestion.
33 *
34 * Additions here should be conservative — a false positive means the user
35 * gets asked an extra question they might have preferred to auto-decide.
36 * A false negative could mean auto-approving a destructive operation.
37 */
38const DESTRUCTIVE_PATTERNS: RegExp[] = [
39 // File system destruction
40 /\brm\s+-rf\b/i,
41 /\bdelete\b/i,
42 /\bremove\s+(directory|folder|files?)\b/i,
43 /\bwipe\b/i,
44 /\bpurge\b/i,
45 /\btruncate\b/i,
47 // Database destruction
48 /\bdrop\s+(table|database|schema|index|column)\b/i,
49 /\bdelete\s+from\b/i,
51 // Git / VCS destruction
52 /\bforce[- ]push\b/i,
53 /\bpush\s+--force\b/i,
54 /\bgit\s+reset\s+--hard\b/i,
55 /\bcheckout\s+--\b/i,
56 /\brestore\s+\.\b/i,
57 /\bclean\s+-f\b/i,
58 /\bbranch\s+-D\b/i,
60 // Deploy / infra destruction
61 /\bkubectl\s+delete\b/i,
62 /\bterraform\s+destroy\b/i,
63 /\brollback\b/i,
65 // Credentials / auth — allow filler words ("the", "my") between verb and noun.
66 // Keep the noun alternation IDENTICAL across revoke/reset/rotate — a noun in
67 // one but not the others is a false-negative safety hole (#2024: "reset my
68 // secret" / "reset my access key" / "revoke my secret" leaked as two-way).
69 // Optional plural `s?` on the noun: \b(...)\b alone cannot match
70 // "credentials" / "tokens" / "passwords".
71 /\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)s?\b/i,
72 /\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)s?\b/i,
73 /\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)s?\b/i,
75 // Scope / architecture forks (reversible with effort — still deserve confirmation)
76 /\barchitectur(e|al)\s+(change|fork|shift|decision)\b/i,
77 /\bdata\s+model\s+change\b/i,
78 /\bschema\s+migration\b/i,
79 /\bbreaking\s+change\b/i,
80];
82/**
83 * Skill-category combinations that are always one-way even when the question
84 * body looks benign. Matches the ownership model: certain skill actions are
85 * inherently high-stakes.
86 */
87const ONE_WAY_SKILL_CATEGORIES = new Set<string>([
88 'cso:approval', // security-audit findings
89 'land-and-deploy:approval', // anything /land-and-deploy asks
90]);
92export interface ClassifyInput {
93 /** Registry id OR ad-hoc id; looked up first */
94 question_id?: string;
95 /** Skill firing the question (for skill-category fallback) */
96 skill?: string;
97 /** Question category (approval | clarification | routing | cherry-pick | feedback-loop) */
98 category?: string;
99 /** Free-form question summary — pattern-matched against destructive keywords */
100 summary?: string;
101}
103export interface ClassifyResult {
104 /** true = treat as one-way door (always ask, never auto-decide) */
105 oneWay: boolean;
106 /** Which check triggered the classification (for audit/debug) */
107 reason: 'registry' | 'skill-category' | 'keyword' | 'default-safe' | 'default-two-way';
108 /** Matched pattern if reason is 'keyword' */
109 matched?: string;
110}
112/**
113 * Classify a question as one-way (always ask) or two-way (can be suppressed).
114 * Returns {oneWay: false, reason: 'default-two-way'} only when no evidence of
115 * one-way nature is found. Errs conservatively otherwise.
116 */
117export function classifyQuestion(input: ClassifyInput): ClassifyResult {
118 // 1. Registry lookup (primary)
119 if (input.question_id) {
120 const registered = getQuestion(input.question_id);
121 if (registered) {
122 return {
123 oneWay: registered.door_type === 'one-way',
124 reason: 'registry',
125 };
126 }
127 }
129 // 2. Skill-category fallback (certain combos are always one-way)
130 if (input.skill && input.category) {
131 const key = `${input.skill}:${input.category}`;
132 if (ONE_WAY_SKILL_CATEGORIES.has(key)) {
133 return { oneWay: true, reason: 'skill-category' };
134 }
135 }
137 // 3. Keyword pattern match (catch destructive questions without registry entry)
138 if (input.summary) {
139 for (const pattern of DESTRUCTIVE_PATTERNS) {
140 if (pattern.test(input.summary)) {
141 return {
142 oneWay: true,
143 reason: 'keyword',
144 matched: pattern.toString(),
145 };
146 }
147 }
148 }
150 // 4. No evidence either way — treat as two-way (can be preference-suppressed).
151 return { oneWay: false, reason: 'default-two-way' };
152}
154/**
155 * Convenience wrapper for the sensitivity check binary.
156 * Returns true if the question must be asked regardless of user preferences.
157 */
158export function isOneWayDoor(input: ClassifyInput): boolean {
159 return classifyQuestion(input).oneWay;
160}
162/**
163 * Export patterns for tests and audit tooling.
164 */
165export const DESTRUCTIVE_PATTERN_LIST = DESTRUCTIVE_PATTERNS;
166export const ONE_WAY_SKILL_CATEGORY_SET = ONE_WAY_SKILL_CATEGORIES;