1- import type { ApprovalAnswer , ApprovalGateConfig , ApprovalReceipt , ApprovalRule , Mode , RuleDecision } from "../../../shared/types.js" ;
1+ import type { ApprovalAnswer , ApprovalDowngrade , ApprovalGateConfig , ApprovalReceipt , ApprovalRule , Mode , RuleDecision } from "../../../shared/types.js" ;
22import type { ApprovalAction } from "../backends/types.js" ;
33import { JevError , type JevQuestion , type JevTransport } from "../routing/jev.js" ;
4- import { digestAction } from "./digest.js" ;
4+ import { digestAction , digestClipped } from "./digest.js" ;
55
66/**
77 * The approval gate: answers an approval from the user's plain-language rules before it reaches a
@@ -13,14 +13,19 @@ import { digestAction } from "./digest.js";
1313 * 3. Rules without one go to Jev (one request, one noul per rule, plus `destructive`), capped at
1414 * MAX_JEV_RULES. A rule applies when its noul ≥ threshold.
1515 * 4. Applying rules combine as never > ask > allow; nothing applies → ask.
16- * 5. Safety downgrades no rule can override: allow on something Jev finds destructive (≥ 0.5) or on a
17- * sandbox/permission escalation → ask.
18- * 6. Jev unavailable → only the deterministic result counts, and the receipt says so.
16+ * 5. Safety downgrades no rule can override, labelled in this order: allow on a sandbox/permission
17+ * escalation, on something Jev finds destructive (≥ 0.5), on a command line that chains or
18+ * redirects commands, or on an action whose digest clipped text Jev needed → ask.
19+ * 6. Jev needed (language rules, or the destructive check on a match allow) but not configured or
20+ * unavailable → only the deterministic result counts, and the decision and receipt say Jev was
21+ * not consulted.
1922 * 7. Aborted while deciding → the approval is answered "no".
2023 */
2124
2225export const MAX_JEV_RULES = 12 ;
2326export const DESTRUCTIVE_THRESHOLD = 0.5 ;
27+ /** The reason recorded when Jev was needed and there is no transport to ask. */
28+ export const JEV_NOT_CONFIGURED = "Jev is not configured or unavailable." ;
2429
2530export const DESTRUCTIVE_QUESTION : JevQuestion = {
2631 type : "noul" ,
@@ -56,8 +61,10 @@ export interface GateDecision {
5661 ms : number ;
5762 /** Jev's `destructive` probability, when it was asked. */
5863 destructive ?: number ;
59- /** Why a rule's allow became an ask, or that Jev could not be consulted. */
60- downgraded ?: "destructive" | "escalation" | "jev-unavailable" ;
64+ /** Why a rule's allow became an ask. */
65+ downgraded ?: ApprovalDowngrade ;
66+ /** Jev was needed for this decision but was not consulted; independent of `downgraded`. */
67+ jevNotConsulted ?: true ;
6168 /** Why Jev could not be consulted, when it could not. */
6269 jevError ?: string ;
6370 /** The turn stopped while the gate was deciding: the approval is answered "no". */
@@ -85,6 +92,7 @@ export function receiptFor(d: GateDecision): ApprovalReceipt | undefined {
8592 ms : d . ms ,
8693 ...( d . destructive !== undefined ? { destructive : round ( d . destructive ) } : { } ) ,
8794 ...( d . downgraded ? { downgraded : d . downgraded } : { } ) ,
95+ ...( d . jevNotConsulted ? { jevNotConsulted : true as const } : { } ) ,
8896 } ;
8997}
9098
@@ -116,6 +124,37 @@ export function matchesRule(match: string, action: Pick<ApprovalAction, "tool" |
116124 return candidates . some ( ( c ) => re . test ( c ) ) ;
117125}
118126
127+ /** Tools whose approval runs a shell command line: Claude's Bash, Codex's command, the scripted engine's shell. */
128+ const COMMAND_TOOLS = new Set ( [ "Bash" , "command" , "shell" ] ) ;
129+
130+ /**
131+ * Shell control operators and line breaks: `;`, `&` (so `&&`, `&`, `2>&1`), `|` (so `||`), backtick,
132+ * `$(`, `<` and `>` (so `<(` and `>(`), CR and LF. Quoting is deliberately not parsed, so
133+ * `grep "a|b"` counts too: a false positive only means a person is asked.
134+ */
135+ const CHAINING = / [ ; & | ` < > \r \n ] | \$ \( / ;
136+
137+ /**
138+ * True when a command action's full command line (never the digest's clipped copy) chains,
139+ * redirects or substitutes commands. An allow rule covers one command, so such a line is asked.
140+ * Both the input's `command` and the title are checked; either one chaining is enough.
141+ */
142+ export function chainsCommands ( action : Pick < ApprovalAction , "tool" | "title" | "input" > ) : boolean {
143+ if ( ! COMMAND_TOOLS . has ( action . tool ) ) return false ;
144+ const command = action . input ?. command ;
145+ const lines = [ action . title . replace ( / ^ \$ / , "" ) , ...( typeof command === "string" ? [ command ] : [ ] ) ] ;
146+ return lines . some ( ( line ) => CHAINING . test ( line ) ) ;
147+ }
148+
149+ /** Why an allow must be asked instead; the first reason wins the label. */
150+ function downgradeOf ( action : ApprovalAction , destructive : number | undefined ) : ApprovalDowngrade | undefined {
151+ if ( action . escalation ) return "escalation" ;
152+ if ( destructive !== undefined && destructive >= DESTRUCTIVE_THRESHOLD ) return "destructive" ;
153+ if ( chainsCommands ( action ) ) return "compound" ;
154+ if ( digestClipped ( action ) ) return "incomplete" ;
155+ return undefined ;
156+ }
157+
119158/** Rules sent to Jev: project-scoped first, then newest (later in the list), at most MAX_JEV_RULES. */
120159export function jevRules ( rules : ApprovalRule [ ] ) : ApprovalRule [ ] {
121160 return rules
@@ -184,9 +223,11 @@ export async function decide(action: ApprovalAction | undefined, ctx: GateContex
184223 // so the destructive downgrade can apply to an exact-match allow too.
185224 const unmatched = jevRules ( rules . filter ( ( r ) => ! r . match ) ) ;
186225 const wouldAllow = combine ( hits ) ?. rule . decision === "allow" ;
226+ const needsJev = unmatched . length > 0 || wouldAllow ;
187227 let destructive : number | undefined ;
188228 let jevError : string | undefined ;
189- if ( ctx . transport && ( unmatched . length || wouldAllow ) ) {
229+ if ( needsJev && ! ctx . transport ) jevError = JEV_NOT_CONFIGURED ;
230+ else if ( needsJev && ctx . transport ) {
190231 const questions : Record < string , JevQuestion > = { destructive : DESTRUCTIVE_QUESTION } ;
191232 unmatched . forEach ( ( r , n ) => { questions [ `rule_${ n } ` ] = ruleQuestion ( r ) ; } ) ;
192233 const state = digestAction ( action , { project : { name : ctx . project . name , branch : ctx . project . branch } , mode : ctx . mode } ) ;
@@ -206,12 +247,10 @@ export async function decide(action: ApprovalAction | undefined, ctx: GateContex
206247 if ( signal ?. aborted ) return { decision : "ask" , source : "none" , ms : ms ( ) , aborted : true } ;
207248
208249 const best = combine ( hits ) ;
209- const base = { ms : ms ( ) , ...( destructive !== undefined ? { destructive } : { } ) , ...( jevError ? { jevError, downgraded : "jev-unavailable" as const } : { } ) } ;
250+ const base = { ms : ms ( ) , ...( destructive !== undefined ? { destructive } : { } ) , ...( jevError ? { jevError, jevNotConsulted : true as const } : { } ) } ;
210251 if ( ! best ) return { decision : "ask" , source : "none" , ...base } ;
211252 const decided : GateDecision = { decision : best . rule . decision , rule : best . rule , source : best . via , p : best . p , ...base } ;
212- if ( decided . decision === "allow" ) {
213- if ( action . escalation ) return { ...decided , decision : "ask" , downgraded : "escalation" } ;
214- if ( destructive !== undefined && destructive >= DESTRUCTIVE_THRESHOLD ) return { ...decided , decision : "ask" , downgraded : "destructive" } ;
215- }
216- return decided ;
253+ // Never and Ask rules stand as they are; only a rule-driven allow can be downgraded.
254+ const downgraded = decided . decision === "allow" ? downgradeOf ( action , destructive ) : undefined ;
255+ return downgraded ? { ...decided , decision : "ask" , downgraded } : decided ;
217256}
0 commit comments