Skip to content

Commit a244850

Browse files
committed
fix(core): extend substitution audit log + dual-check stripped form (#4386 R4)
Round-4 audit-log consistency fixes from wenshao: 1. **Dual-check stripped form in audit predicate** — round 3's `maybeLogSubstitutionBypass` only ran `detectCommandSubstitution` on the raw command, but `buildShellExecWarnings` (the confirmation-dialog warning helper extracted in round 3) checks BOTH the raw and the `stripShellWrapper`-stripped forms. For wrappers like `bash -c 'echo $(cat secret)'` the `$(` sits inside the outer single quotes, so raw-check returns false but the inner shell still expands the substitution. Result: dialog showed the warning, audit log silently dropped it — exactly the wrapper pattern an exfiltration attack would use. Refactored the helper to extract a pure predicate `shouldAuditSubstitutionBypass` that does the dual-check, exported for unit testing. 2. **JSDoc correction** — round 3's JSDoc claimed "DEBUG-level log here so the signal exists when `DEBUG=*` is set". Both clauses were wrong: the call uses `debugLogger.warn` (WARN level), and `debugLogger` is controlled by `QWEN_DEBUG_LOG_FILE` (active by default) rather than the `DEBUG=*` convention of the `debug` npm package. Rewrote the doc to match the real semantics. 3. **Audit log on three more auto-approve bypass paths** — round 3 only covered the YOLO and auto-mode-`approved` bypasses, missing three other paths that auto-approve without invoking `getConfirmationDetails()`: - PM `'allow'` fast path (coreToolScheduler.ts:1664) — fires when an allow rule matches a substitution-bearing command (e.g. `allow Bash(python3 *)` + `python3 -c "$(...)"`). - permission-request hook `shouldAllow` (line ~1896) — fires when an external hook grants permission directly. - `autoApproveCompatiblePendingTools` sibling-tool ProceedAlways (line ~3300) — fires when one tool's user-issued ProceedAlways outcome rolls up to a sister tool. Uses `canonicalToolName` to normalise the legacy name in the audit log. New `SubstitutionBypassReason` discriminator union surfaces the bypass path in the log so operators can distinguish them. 4. **Unit test coverage** — added a `shouldAuditSubstitutionBypass` describe block to `coreToolScheduler.test.ts` covering all 8 branches: non-shell tool, missing args, non-string command, absent substitution, direct substitution (shell + monitor), the load-bearing wrapper case (proves the dual-check works), backtick substitution, and env-prefix substitution. Out of scope: reviewer also suggested forcing `'ask'` whenever substitution is detected with a matching allow rule. Declined — would partially re-introduce #4093 (substitution can't be allowed via rules even with explicit user intent), and overrides the allow-rule "trust this pattern" semantics. The audit-log extension above gives operators the visibility they asked for without overriding user-configured permission policy. Closes wenshao R4 findings: dual-check (cids 3293074365, 3293075616), JSDoc level/envvar (cids 3293074371, 3293075619), audit on PM-allow (cid 3293078740 — partial), audit on hook + sibling-auto (rid 4351040390 non-diff), and test coverage for the predicate (cid 3293078758 — partial).
1 parent 42debd1 commit a244850

2 files changed

Lines changed: 158 additions & 11 deletions

File tree

packages/core/src/core/coreToolScheduler.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ import {
3838
CoreToolScheduler,
3939
convertToFunctionResponse,
4040
extractToolFilePaths,
41+
shouldAuditSubstitutionBypass,
4142
} from './coreToolScheduler.js';
43+
import { ToolNames } from '../tools/tool-names.js';
4244
import type { Part, PartListUnion } from '@google/genai';
4345
import {
4446
MockModifiableTool,
@@ -8361,3 +8363,89 @@ describe('CoreToolScheduler shell-tool promote integration (#3831 PR-2)', () =>
83618363
expect(sawPromoteAcWhileExecuting).toBe(true);
83628364
});
83638365
});
8366+
8367+
// Regression coverage for PR #4386 round 4: the audit predicate driving
8368+
// `maybeLogSubstitutionBypass` must (a) only fire for shell-like tool
8369+
// names, (b) require a string `command` arg, and (c) detect substitution
8370+
// against BOTH the raw command and the stripped form (`stripShellWrapper`),
8371+
// so wrappers like `bash -c 'echo $(...)' ` where `$(` sits inside outer
8372+
// single quotes still trigger the audit log. The dual-check matches
8373+
// `buildShellExecWarnings` in `shell-utils.ts`.
8374+
describe('shouldAuditSubstitutionBypass', () => {
8375+
it('returns false for non-shell tool names', () => {
8376+
expect(
8377+
shouldAuditSubstitutionBypass('read_file', {
8378+
command: 'echo $(curl evil)',
8379+
}),
8380+
).toBe(false);
8381+
expect(
8382+
shouldAuditSubstitutionBypass('write_file', {
8383+
command: '`whoami`',
8384+
}),
8385+
).toBe(false);
8386+
});
8387+
8388+
it('returns false for shell tools with missing or non-string command', () => {
8389+
expect(shouldAuditSubstitutionBypass(ToolNames.SHELL, undefined)).toBe(
8390+
false,
8391+
);
8392+
expect(shouldAuditSubstitutionBypass(ToolNames.SHELL, {})).toBe(false);
8393+
expect(
8394+
shouldAuditSubstitutionBypass(ToolNames.SHELL, { command: 42 }),
8395+
).toBe(false);
8396+
});
8397+
8398+
it('returns false for shell command without substitution', () => {
8399+
expect(
8400+
shouldAuditSubstitutionBypass(ToolNames.SHELL, {
8401+
command: 'npm install --save-dev vitest',
8402+
}),
8403+
).toBe(false);
8404+
});
8405+
8406+
it('returns true for direct substitution in shell tool', () => {
8407+
expect(
8408+
shouldAuditSubstitutionBypass(ToolNames.SHELL, {
8409+
command: 'echo $(cat /etc/shadow)',
8410+
}),
8411+
).toBe(true);
8412+
});
8413+
8414+
it('returns true for substitution in monitor tool', () => {
8415+
expect(
8416+
shouldAuditSubstitutionBypass(ToolNames.MONITOR, {
8417+
command: 'tail -f $(ls /var/log | head -1)',
8418+
}),
8419+
).toBe(true);
8420+
});
8421+
8422+
it('returns true when substitution lives inside a single-quoted bash -c wrapper (dual-check)', () => {
8423+
// `$(` is inside the outer single quotes, so `detectCommandSubstitution`
8424+
// on the raw command returns false. `stripShellWrapper` unwraps the
8425+
// outer `bash -c '...'` and yields the inner `echo $(cat secret.txt)`,
8426+
// where detection then fires. Without the dual-check this would return
8427+
// false and the audit log would silently miss the exact wrapper
8428+
// pattern an exfiltration attack would use.
8429+
expect(
8430+
shouldAuditSubstitutionBypass(ToolNames.SHELL, {
8431+
command: `bash -c 'echo $(cat secret.txt)'`,
8432+
}),
8433+
).toBe(true);
8434+
});
8435+
8436+
it('returns true for backtick substitution', () => {
8437+
expect(
8438+
shouldAuditSubstitutionBypass(ToolNames.SHELL, {
8439+
command: 'echo `whoami`',
8440+
}),
8441+
).toBe(true);
8442+
});
8443+
8444+
it('returns true for env-prefix substitution', () => {
8445+
expect(
8446+
shouldAuditSubstitutionBypass(ToolNames.SHELL, {
8447+
command: 'FOO=$(cat /etc/passwd) ls',
8448+
}),
8449+
).toBe(true);
8450+
});
8451+
});

packages/core/src/core/coreToolScheduler.ts

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -790,28 +790,63 @@ function partitionToolCalls(calls: ScheduledToolCall[]): ToolBatch[] {
790790

791791
/**
792792
* Tool names whose confirmation prompt may emit a command-substitution
793-
* warning. When the L5 override (YOLO / auto-approve) skips
794-
* `getConfirmationDetails()`, that warning never reaches the user. The
795-
* substitution is still *executed* — by design, since #4093 made
796-
* substitution-bearing commands overridable — but operators
797-
* troubleshooting an exfiltration incident need an audit trail. Emit a
798-
* DEBUG-level log here so the signal exists when `DEBUG=*` is set,
799-
* without spamming every YOLO invocation.
793+
* warning. When any path bypasses `getConfirmationDetails()` — YOLO,
794+
* auto-mode auto-approve, PM allow-rule fast path, permission-request
795+
* hook allow, or sibling-tool auto-approval — that warning never
796+
* reaches the user. The substitution is still *executed* — by design,
797+
* since #4093 made substitution-bearing commands overridable — but
798+
* operators troubleshooting an exfiltration incident need an audit
799+
* trail. Emit a WARN-level log to the debug log file (controlled by
800+
* `QWEN_DEBUG_LOG_FILE`, active by default) so the signal is present
801+
* in every session's debug log without spamming the TUI.
800802
*/
801803
const SHELL_LIKE_TOOL_NAMES_FOR_SUBST_AUDIT: ReadonlySet<string> = new Set([
802804
ToolNames.SHELL,
803805
ToolNames.MONITOR,
804806
]);
805807

808+
/** Bypass paths that skip the confirmation-dialog warning surface. */
809+
type SubstitutionBypassReason =
810+
| 'yolo'
811+
| 'auto-approve'
812+
| 'allow-rule'
813+
| 'hook'
814+
| 'sibling-auto';
815+
816+
/**
817+
* Pure predicate: would `maybeLogSubstitutionBypass` actually emit a
818+
* warning for this tool + args shape? Extracted so callers can be
819+
* unit-tested without mocking the module-private `debugLogger`.
820+
*
821+
* Checks substitution against BOTH the raw command and the stripped
822+
* form (`stripShellWrapper`). This matches the dual-check that
823+
* `buildShellExecWarnings` (`shell-utils.ts`) uses, so the audit log
824+
* and the confirmation-dialog warning fire on the same commands.
825+
* Without the stripped check, wrappers like `bash -c 'echo $(cat /etc/shadow)'`
826+
* (where `$(` sits inside single quotes at the outer level) would slip
827+
* past the audit log while still showing a warning in the dialog
828+
* (PR #4386 R4).
829+
*/
830+
export function shouldAuditSubstitutionBypass(
831+
canonicalName: string,
832+
args: Record<string, unknown> | undefined,
833+
): boolean {
834+
if (!SHELL_LIKE_TOOL_NAMES_FOR_SUBST_AUDIT.has(canonicalName)) return false;
835+
const cmd = args?.['command'];
836+
if (typeof cmd !== 'string') return false;
837+
if (detectCommandSubstitution(cmd)) return true;
838+
const stripped = stripShellWrapper(cmd);
839+
return stripped !== cmd && detectCommandSubstitution(stripped);
840+
}
841+
806842
function maybeLogSubstitutionBypass(
807843
canonicalName: string,
808844
args: Record<string, unknown> | undefined,
809845
approvalMode: ApprovalMode,
810-
bypassReason: 'yolo' | 'auto-approve',
846+
bypassReason: SubstitutionBypassReason,
811847
): void {
812-
if (!SHELL_LIKE_TOOL_NAMES_FOR_SUBST_AUDIT.has(canonicalName)) return;
813-
const cmd = args?.['command'];
814-
if (typeof cmd !== 'string' || !detectCommandSubstitution(cmd)) return;
848+
if (!shouldAuditSubstitutionBypass(canonicalName, args)) return;
849+
const cmd = args!['command'] as string;
815850
debugLogger.warn(
816851
`Auto-approving ${canonicalName} command with substitution (mode=${approvalMode}, bypass=${bypassReason}): ${cmd}`,
817852
);
@@ -1676,6 +1711,12 @@ export class CoreToolScheduler {
16761711
recordAllow(this.config.getAutoModeDenialState()),
16771712
);
16781713
}
1714+
maybeLogSubstitutionBypass(
1715+
canonicalName,
1716+
reqInfo.args,
1717+
approvalMode,
1718+
'allow-rule',
1719+
);
16791720
this.setToolCallOutcome(
16801721
reqInfo.callId,
16811722
ToolConfirmationOutcome.ProceedAlways,
@@ -1904,6 +1945,18 @@ export class CoreToolScheduler {
19041945
hookResult.updatedInput,
19051946
);
19061947
}
1948+
// Audit-log substitution bypass: the hook just skipped
1949+
// the user-facing confirmation dialog (which would have
1950+
// shown the warning). Use the post-hook args so any
1951+
// `updatedInput` modification is what gets logged.
1952+
maybeLogSubstitutionBypass(
1953+
canonicalName,
1954+
(hookResult.updatedInput as
1955+
| Record<string, unknown>
1956+
| undefined) ?? reqInfo.args,
1957+
approvalMode,
1958+
'hook',
1959+
);
19071960
await confirmationDetails.onConfirm(
19081961
ToolConfirmationOutcome.ProceedOnce,
19091962
);
@@ -3295,6 +3348,12 @@ export class CoreToolScheduler {
32953348
const { finalPermission } = flowResult;
32963349

32973350
if (finalPermission === 'allow') {
3351+
maybeLogSubstitutionBypass(
3352+
canonicalToolName(pendingTool.request.name),
3353+
pendingTool.request.args as Record<string, unknown>,
3354+
this.config.getApprovalMode(),
3355+
'sibling-auto',
3356+
);
32983357
this.setToolCallOutcome(
32993358
pendingTool.request.callId,
33003359
ToolConfirmationOutcome.ProceedAlways,

0 commit comments

Comments
 (0)