fix(gateguard): surface graduated recovery hints

Change-Id: I6ade0a2a54a26bd5721c62edf7efa462e8043a08
Co-authored-by: TRAE CLI <traecli@bytedance.com>
This commit is contained in:
wellkilo
2026-08-29 14:55:14 -04:00
committed by haelyra
co-authored by TRAE CLI
parent c40d0e4f7c
commit a4d72b2271
2 changed files with 42 additions and 7 deletions
+27 -7
View File
@@ -42,6 +42,10 @@ const MAX_SESSION_KEYS = 50;
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
const EDIT_WRITE_NARROW_RECOVERY_HINT =
'Narrow recovery: add a matching path glob to `GATEGUARD_EXEMPT_GLOBS` to skip first-touch Edit/Write checks without disabling destructive Bash checks.';
const ROUTINE_BASH_NARROW_RECOVERY_HINT =
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash checks remain active.';
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
@@ -1097,7 +1101,7 @@ function condensedGateMsg(action, filePath, ordinal) {
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
'(ECC_GATEGUARD=off disables this gate.)'
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)'
);
}
@@ -1128,9 +1132,15 @@ function routineBashMsg() {
].join('\n');
}
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID], narrowRecoveryHint = '') {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
return [message, '', `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`].join('\n');
const recoveryLines = narrowRecoveryHint ? [narrowRecoveryHint, ''] : [];
return [
message,
'',
...recoveryLines,
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
}
function isSubagentInvocation(data) {
@@ -1148,12 +1158,15 @@ function isSubagentInvocation(data) {
function denyResult(reason, options = {}) {
const includeRecoveryHint = options.includeRecoveryHint !== false;
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
const narrowRecoveryHint = typeof options.narrowRecoveryHint === 'string' ? options.narrowRecoveryHint : '';
return {
stdout: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
permissionDecisionReason: includeRecoveryHint
? withRecoveryHint(reason, hookIds, narrowRecoveryHint)
: reason
}
}),
exitCode: 0
@@ -1210,7 +1223,9 @@ function run(rawInput) {
const action = toolName === 'Edit' ? 'edit' : 'creation';
return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false });
}
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath), {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
return rawInput; // allow
@@ -1232,7 +1247,9 @@ function run(rawInput) {
if (denials > getFullDenialBudget()) {
return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false });
}
return denyResult(editGateMsg(filePath));
return denyResult(editGateMsg(filePath), {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
}
return rawInput; // allow
@@ -1268,7 +1285,10 @@ function run(rawInput) {
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
return denyResult(routineBashMsg(), {
hookIds: [BASH_HOOK_ID],
narrowRecoveryHint: ROUTINE_BASH_NARROW_RECOVERY_HINT
});
}
return rawInput; // allow
+15
View File
@@ -145,6 +145,8 @@ function runTests() {
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('import/require'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/app.js'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'Edit denial should show the path-scoped exemption control');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'Edit denial should not suggest the routine Bash control');
})
)
passed++;
@@ -538,6 +540,8 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'), 'denial reason should show the direct recovery env toggle');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'), 'denial reason should mention the existing hook-id disable control');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'Edit/Write denial should show the path-scoped exemption control');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'Edit/Write denial should not suggest the routine Bash control');
})
)
passed++;
@@ -558,6 +562,9 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(reason.includes('pre:bash:gateguard-fact-force'), 'routine Bash denial should show the Bash hook ID');
assert.ok(!reason.includes('pre:edit-write:gateguard-fact-force'), 'routine Bash denial should not show the Edit/Write hook ID as the targeted disable');
assert.ok(reason.includes('GATEGUARD_BASH_ROUTINE_DISABLED=1'), 'routine Bash denial should show the narrow routine-gate control');
assert.ok(reason.includes('destructive Bash checks remain active'), 'routine Bash denial should preserve the destructive-check safety boundary');
assert.ok(!reason.includes('GATEGUARD_EXEMPT_GLOBS'), 'routine Bash denial should not suggest the Edit/Write path control');
})
)
passed++;
@@ -577,6 +584,9 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'), 'destructive gate should not advertise disabling GateGuard');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'), 'destructive gate should not advertise disabling its hook');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'destructive gate should not advertise the routine-only bypass');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'destructive gate should not advertise the Edit/Write path exemption');
})
)
passed++;
@@ -602,6 +612,7 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/multi-a.js'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'MultiEdit denial should show the path-scoped exemption control');
})
)
passed++;
@@ -2556,6 +2567,7 @@ function runTests() {
assert.ok(!reason.includes('present these facts'), 'no repeated four-fact block');
assert.ok(!reason.includes('\n'), 'condensed message is a single line');
assert.ok(reason.includes('ECC_GATEGUARD=off'), 'condensed message keeps a recovery hint');
assert.ok(reason.includes('GATEGUARD_EXEMPT_GLOBS'), 'condensed Edit denial keeps the path-scoped recovery hint');
})
)
passed++;
@@ -2571,6 +2583,8 @@ function runTests() {
const secondReason = second.hookSpecificOutput.permissionDecisionReason;
assert.ok(firstReason.includes('denial #6'), `expected ordinal 6, got: ${firstReason}`);
assert.ok(secondReason.includes('denial #7'), `expected ordinal 7, got: ${secondReason}`);
assert.ok(firstReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'condensed Write denial keeps the path-scoped recovery hint');
assert.ok(!firstReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'condensed Write denial should not suggest the routine Bash control');
assert.notStrictEqual(firstReason, secondReason, 'successive denials must differ so they cannot compound verbatim');
})
)
@@ -2635,6 +2649,7 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('denial #5'));
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('present these facts'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'condensed MultiEdit denial keeps the path-scoped recovery hint');
})
)
passed++;