Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c018d25
fix(hooks): never follow redirects in HTTP hooks
wenshao Aug 2, 2026
cb6ec7c
fix(settings): strip allowedHttpHookUrls from workspace scope
wenshao Aug 2, 2026
38754ef
fix(hooks): never resolve Qwen-internal secrets in env interpolation
wenshao Aug 2, 2026
6d61cf6
fix(hooks): gate project frontmatter hooks on folder trust
wenshao Aug 2, 2026
aed434a
fix(core): apply the internal-secrets denylist to the core envVarReso…
wenshao Aug 2, 2026
5813462
Merge branch 'main' into fix/hooks-security
qwen-code-dev-bot Aug 2, 2026
50b7f86
fix(hooks): case-insensitive secret denylist, narrow fast-path import…
qwen-code-ci-bot Aug 2, 2026
1353d0b
fix(hooks): meaningful casing tests, single env resolver, workspace-s…
qwen-code-ci-bot Aug 2, 2026
e3accbe
fix(hooks): case-insensitive child-env sanitize, denylist-first resol…
qwen-code-ci-bot Aug 3, 2026
9569dbf
fix(core): channel-config secret denylist, skill allowedTools trust g…
qwen-code-ci-bot Aug 3, 2026
832f82b
fix(hooks): throw on internal-secret channel refs, sanitize redirect …
qwen-code-ci-bot Aug 3, 2026
e83f5e1
fix(hooks): event-scoped redirect warnings, merged systemMessages (#8…
qwen-code-ci-bot Aug 3, 2026
199aaee
fix(cli): warn when skill allowedTools trust gate fires in slash-comm…
qwen-code-ci-bot Aug 3, 2026
0f9e98e
fix(core): apply skill side effects when folder trust is granted mid-…
qwen-code-ci-bot Aug 3, 2026
fb7b426
fix(core): honor skill side-effect deferral contract, pin invariants …
qwen-code-ci-bot Aug 4, 2026
6c44667
Merge branch 'main' into fix/hooks-security
qwen-code-dev-bot Aug 4, 2026
b10ec55
fix(cli): remap core envVarResolver subpath in dev loader (#8396)
qwen-code-dev-bot Aug 4, 2026
935ae7e
Merge branch 'main' into fix/hooks-security
qwen-code-dev-bot Aug 4, 2026
502a8aa
fix(hooks): workspace whitelist may only narrow; trust session subage…
qwen-code-dev-bot Aug 4, 2026
846ec2d
fix(hooks): roll back once-hook slot when a 3xx is never delivered (#…
qwen-code-dev-bot Aug 4, 2026
edb11de
fix(hooks): fail closed and scan linearly when narrowing workspace ho…
qwen-code-dev-bot Aug 4, 2026
817e1dc
fix(hooks): harden hook whitelist narrowing and one-shot warning deli…
qwen-code-dev-bot Aug 5, 2026
90002cb
fix(hooks): close home-root trust bypass and non-ASCII pattern diverg…
qwen-code-dev-bot Aug 5, 2026
f36390e
Merge branch 'main' into fix/hooks-security
qwen-code-dev-bot Aug 5, 2026
eedd924
fix(hooks): make home-root trust gates immune to per-agent root rebin…
qwen-code-dev-bot Aug 5, 2026
dce013c
Merge branch 'main' into fix/hooks-security
wenshao Aug 6, 2026
3ef677f
chore(hooks): merge main into fix/hooks-security
qwen-code-dev-bot Aug 6, 2026
d4a76b9
chore(hooks): merge main into fix/hooks-security
qwen-code-dev-bot Aug 13, 2026
ccf1a7a
Merge branch 'main' into fix/hooks-security
wenshao Aug 15, 2026
ad00d83
Merge branch 'main' into fix/hooks-security
wenshao Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ By default, HTTP hooks cannot target private or link-local IP ranges. In platfor

- This setting is **only honored from User, System, and SystemDefaults settings scopes**. A value set in Workspace (project) settings is ignored and logged as a warning, so a cloned repository can never self-grant this bypass.
- The flag relaxes only the general private/CGNAT/link-local **range** checks. Cloud metadata endpoints stay blocked in every configuration: the `BLOCKED_HOSTS` list is matched literally (`metadata.google.internal`, `metadata.azure.internal`, ...), and the metadata IPs `169.254.169.254` and `100.100.100.200` are blocked in all serialized forms (including IPv4-mapped IPv6 such as `::ffff:a9fe:a9fe`) and after DNS resolution.
- The `security.allowedHttpHookUrls` whitelist still applies independently. In managed environments, pair this flag with a whitelist so only the intended internal endpoints are reachable.
- The `security.allowedHttpHookUrls` whitelist still applies independently. In managed environments, pair this flag with a whitelist so only the intended internal endpoints are reachable. Like this flag, the whitelist is **honored from User, System, and SystemDefaults settings scopes**; a value set in Workspace (project) settings can only _narrow_ the User or SystemDefaults whitelist and is logged as a warning: workspace entries that no higher-scope entry covers are dropped, when no higher scope sets a whitelist the workspace value is ignored entirely, and a System-scope whitelist always takes precedence over the workspace value (an empty whitelist means "allow all", so a repository can neither widen where hook payloads may be sent nor establish a whitelist of its own).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R15-2: The PR description says "Both keys are now stripped from workspace scope" (and Risk & Scope says workspaces "must move it to user settings"), but the shipped behavior — this docs line, narrowWorkspaceHookSecurityOverrides in packages/cli/src/config/settings.ts, the schema descriptions, and the narrowing tests — is narrow-only intersection: a trusted workspace may still NARROW a higher-scope whitelist; only uncovered entries are dropped.

Probe: user ['https://hooks.corp.com/*'] + workspace ['https://hooks.corp.com/ci/*'] → effective ['https://hooks.corp.com/ci/*'] (the "should let a trusted workspace whitelist narrow the user whitelist" test passes at this commit).

Concrete cost: a maintainer validating claim 2 reads "stripped" and concludes a repository can never influence the effective whitelist, while the shipped code lets a trusted repo change it by narrowing; conversely, a user following the migration note moves a deliberately narrowing project whitelist to user scope and loses the per-project restriction.

Suggested fix: update the PR body before merge — describe item 2 as narrow-only intersection (workspace entries may only narrow a higher-scope whitelist; uncovered entries dropped; workspace ignored entirely when no higher scope sets one; System precedence), and replace the blanket migration note.

中文说明

[Suggestion] PR 描述写的是 "Both keys are now stripped from workspace scope"(风险与范围一节还要求把工作区白名单"移到 user settings"),但实际实现——本行文档、packages/cli/src/config/settings.ts 的 narrowWorkspaceHookSecurityOverrides、schema 描述以及收窄测试——是“仅收窄”的交集语义:受信任的工作区仍可收窄更高作用域的白名单,只有未被覆盖的条目会被丢弃。

探针:user ['https://hooks.corp.com/*'] + workspace ['https://hooks.corp.com/ci/*'] → 生效 ['https://hooks.corp.com/ci/*']("should let a trusted workspace whitelist narrow the user whitelist" 测试在本 commit 通过)。

具体代价:维护者验证第 2 项时读到 "stripped",会以为仓库永远无法影响生效白名单,而实际代码允许受信仓库通过收窄来改变它;反过来,用户照迁移说明把刻意收窄用的项目白名单移到 user 作用域,反而失去按项目粒度的限制。

修复建议:合并前更新 PR 正文——把第 2 项描述为仅收窄交集(workspace 条目只能收窄更高作用域白名单;未覆盖条目被丢弃;更高作用域未设置时 workspace 值被完全忽略;System 优先),并替换一刀切的迁移说明。

— qwen3.8-max via Qwen Code /review (v0.21.6)


> **Warning:** Enabling this flag lets hooks reach internal infrastructure on your network. Enable it only in trusted, managed settings — never in a repository you do not control.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,28 @@ async function main() {
const coreSrcUrl = pathToFileURL(
path.join(repoRoot, 'packages', 'core', 'index.ts'),
).href;
// Core's package.json maps the ./envVarResolver subpath to dist, which a
// fresh checkout lacks (and a built one may keep stale). `after` mode
// pulls it in via SkillReviewDialog -> config/settings.js, so it needs
// the same source remap as the bare specifier (mirrors scripts/dev.js).
const envVarResolverSrcUrl = pathToFileURL(
path.join(
repoRoot,
'packages',
'core',
'src',
'utils',
'envVarResolver.ts',
),
).href;
const loader = `
export function resolve(specifier, context, nextResolve) {
if (specifier === '@qwen-code/qwen-code-core') {
return { shortCircuit: true, url: '${coreSrcUrl}', format: 'module' };
}
if (specifier === '@qwen-code/qwen-code-core/envVarResolver') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R13-2 (still standing from round 13; re-detected independently this round by the build-test efficacy probe, harness validated): the envVarResolver subpath source-remap added to this file is unreachable by every test command in the project — the file sits outside every npm workspace. — Concrete cost: if the remap specifier or mapped path is wrong (or drifts from core package.json's ./envVarResolver subpath or from scripts/dev.js), an after-mode terminal capture of the SkillReviewDialog flow fails at import time on a fresh checkout, and nothing automated catches the break.

Suggested fix: confirm the terminal-capture harness run for this PR actually executed this path in CI, or exercise the loader remap in a collected test.

中文说明

R13-2(第 13 轮遗留,本轮仍然成立;本轮 build-test 有效性探针独立再次发现,harness 已验证):本文件新增的 envVarResolver 子路径源码重映射对项目内任何测试命令都不可达——该文件位于所有 npm workspace 之外。——具体代价:若重映射的说明符或目标路径错误(或与 core package.json 的 ./envVarResolver 子路径、scripts/dev.js 发生漂移),新检出上的 after 模式 SkillReviewDialog 终端采集会在 import 时失败,且没有任何自动化手段能捕获该破坏。修复建议:确认本 PR 的 terminal-capture harness 运行在 CI 中确实执行了该路径,或在一个被收集的测试中执行该 loader 重映射。

— qwen3.8-max via Qwen Code /review (v0.21.6)

return { shortCircuit: true, url: '${envVarResolverSrcUrl}', format: 'module' };
}
Comment on lines +252 to +254

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R13-2: The 17 lines added to this file (the envVarResolver subpath source-remap in the embedded ESM loader) are unreachable by every test command in the project — the file sits outside every npm workspace, and every vitest config under integration-tests/ includes only **/*.test.ts-style globs, so nothing collects it. — Concrete cost: if the specifier string, the remapped path, or the shortCircuit shape is wrong, nothing in CI or any suite run goes red; the defect surfaces only as a runtime resolution failure the next time a human manually runs the skill-review-harness after mode (which imports SkillReviewDialog → cli config/settings.js → the envVarResolver subpath), with no gate having ever validated it. — Suggested fix: exercise the remap somewhere a test command collects it (e.g. a scenario or a small *.test.ts under integration-tests/ asserting the loader resolves the subpath to the source file), or attach a manual terminal-capture run of the after mode as PR evidence.

中文说明

[Suggestion] R13-2:本文件新增的 17 行(内嵌 ESM loader 中的 envVarResolver 子路径源码重映射)无法被项目中任何测试命令触达——该文件不在任何 npm workspace 内,而 integration-tests/ 下的每个 vitest 配置只收录 **/*.test.ts 形式的文件,因此没有任何套件会加载它。—— 具体代价:如果 specifier 字符串、重映射路径或 shortCircuit 形态有误,CI 或任何套件运行都不会变红;该缺陷只会在下次有人手动运行 skill-review-harness 的 after 模式时(其导入链为 SkillReviewDialog → cli config/settings.jsenvVarResolver 子路径)以运行时解析失败的形式出现,而此前没有任何门禁验证过它。—— 修复建议:在某个会被测试命令收录的位置行使该重映射(例如一个场景,或 integration-tests/ 下一个断言 loader 能把该子路径解析到源文件的小型 *.test.ts),或者把一次 after 模式的手动 terminal-capture 运行结果作为 PR 证据附上。

— qwen3.8-max via Qwen Code /review (v0.21.6)

return nextResolve(specifier, context);
}
`;
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/commands/channel/config-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,3 +805,84 @@ describe('parseChannelConfig', () => {
);
});
});

describe('internal-secret denylist', () => {
const SECRET = 'QWEN_SERVER_TOKEN';

afterEach(() => {
delete process.env[SECRET];
delete process.env[SECRET.toLowerCase()];
});

it('resolveEnvVars rejects Qwen-internal secrets instead of yielding the name as a value', () => {
process.env[SECRET] = 'daemon-secret';
expect(() => resolveEnvVars(`$${SECRET}`)).toThrow(
`${SECRET} is a Qwen-internal secret`,
);
});

it('resolveEnvVars rejects case variants (Windows-style process.env)', () => {
process.env[SECRET.toLowerCase()] = 'daemon-secret';
expect(() => resolveEnvVars(`$${SECRET.toLowerCase()}`)).toThrow(
'is a Qwen-internal secret',
);
});

it('parseChannelConfig rejects internal secrets in channel credentials', async () => {
process.env[SECRET] = 'daemon-secret';

await expect(
parseChannelConfig('bot', {
type: 'github',
token: `$${SECRET}`,
}),
).rejects.toThrow(`${SECRET} is a Qwen-internal secret`);

await expect(
parseChannelConfig(
'bot',
{ type: 'github', token: `$${SECRET}` },
undefined,
{ resolveEnvVars: 'available' },
),
).rejects.toThrow(`${SECRET} is a Qwen-internal secret`);
});

it('webhook secret and secretEnv reject Qwen-internal secrets', async () => {
process.env[SECRET] = 'daemon-secret';

await expect(
parseChannelConfig('dingtalk-main', {
type: 'bare',
token: 'token',
webhooks: {
sources: {
custom: {
secret: `$${SECRET}`,
targets: { default: { chatId: 'group-1', senderId: 'webhook' } },
},
},
},
}),
).rejects.toThrow(`${SECRET} is a Qwen-internal secret`);

// secretEnv used to return the variable *name* as the secret — a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R13-5 (still standing from round 13): this rationale comment misdescribes the pre-fix behavior. Verified against the merge base: resolveWebhookSecretEnv resolved the variable's VALUE (const envValue = env[envName]; … return envValue;) and threw when unset — it never returned the name. — Concrete cost: a maintainer reading this security test gets an inverted history of what the old code did; the comment is the only rationale documentation for the secretEnv denylist.

Suggested fix: reword to describe the actual pre-fix behavior (resolved the variable's value into channel config).

中文说明

R13-5(第 13 轮遗留,本轮仍然成立):该原理性注释对修复前行为的描述有误。已对合并基核实:resolveWebhookSecretEnv 解析的是变量的值(const envValue = env[envName]; … return envValue;),未设置时抛错——它从不返回变量名。——具体代价:维护者阅读该安全测试时会得到关于旧代码行为的颠倒历史;而该注释是 secretEnv 拒绝列表唯一的原理说明。修复建议:改写为真实的修复前行为(把变量的值解析进 channel 配置)。

— qwen3.8-max via Qwen Code /review (v0.21.6)

// public constant that makes HMAC verification bypassable.
Comment on lines +869 to +870

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R13-5: This rationale comment misdescribes the pre-fix behavior. Verified against the merge base: resolveWebhookSecretEnv resolved the variable's VALUE (const envValue = env[envName]; … return envValue;) — it never returned the name. The name-as-secret behavior was this PR's own intermediate denylist branch (commit 9569dbf71), replaced by the throw in 832f82b47 — the same commit that introduced this comment. — Concrete cost: once merged, "used to" reads as the last-shipped behavior, which resolved the value: with QWEN_SERVER_TOKEN set, the live daemon token itself would have become the HMAC secret of a repo-configured webhook — a strictly worse exposure than the "public constant" described here. A future maintainer or incident reviewer using this test as the canonical denylist rationale would understate the historical exposure and could wrongly scope rotation/audit. (The adjacent code comment in config-utils.ts is fine — it describes the hypothetical keep-the-placeholder design, not merge-base history.)

Suggested change
// secretEnv used to return the variable *name* as the secret — a
// public constant that makes HMAC verification bypassable.
// Before the denylist, secretEnv resolved the variable's *value* into
// the webhook secret — with QWEN_SERVER_TOKEN set, the live daemon token
// itself became the HMAC secret of a repo-configured webhook.
中文说明

[Suggestion] R13-5:该理由注释误述了修复前的行为。已对合并基核实:resolveWebhookSecretEnv 解析的是变量的const envValue = env[envName]; … return envValue;)——从未返回过变量名。把名字当 secret 的行为是本 PR 自己的中间态 denylist 分支(commit 9569dbf71),在 832f82b47 中被改为抛错——正是引入本注释的同一 commit。—— 具体代价:合入后 "used to" 会被读作上一个已发布行为,而那个行为解析的是值:在设置了 QWEN_SERVER_TOKEN 时,活跃的 daemon token 本身会成为仓库所配置 webhook 的 HMAC secret——比这里描述的"公开常量"严重得多的暴露面。未来以本测试作为 denylist 权威理由的维护者或事件复盘者会低估历史暴露面,并可能错误地界定凭据轮换/审计范围。(config-utils.ts 中相邻的代码注释没有问题——它描述的是假设的"保留占位符"设计,而非合并基历史。)

— qwen3.8-max via Qwen Code /review (v0.21.6)

await expect(
parseChannelConfig('dingtalk-main', {
type: 'bare',
token: 'token',
webhooks: {
sources: {
custom: {
secretEnv: SECRET,
targets: { default: { chatId: 'group-1', senderId: 'webhook' } },
},
},
},
}),
).rejects.toThrow(
`references a Qwen-internal secret (${SECRET}); internal secrets are never resolved into channel config`,
);
});
});
21 changes: 21 additions & 0 deletions packages/cli/src/commands/channel/config-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ChannelWebhookSourceConfig,
ChannelWebhookTargetConfig,
} from '@qwen-code/channel-base';
import { isInternalSecretEnvVar } from '@qwen-code/qwen-code-core/envVarResolver';
import { resolveChannelCwd } from './channel-cwd.js';
import { getPlugin, supportedTypes } from './channel-registry.js';

Expand All @@ -29,6 +30,16 @@ export function resolveEnvVars(
}
if (value.startsWith('$')) {
const envName = value.substring(1);
// Qwen-internal secrets never resolve into channel config: resolved
// values are sent to repo-configured endpoints. The core resolver
// preserves placeholders, but channel config throws at config time
// like every other unresolvable reference — silently keeping the
// literal would turn a secret *name* into a public, guessable value.
if (isInternalSecretEnvVar(envName)) {
Comment on lines +37 to +38

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R17-7: No test pins the $$-escape-vs-denylist ordering. The new denylist checks in resolveEnvVars and resolveConfigEnvVar are deliberately placed AFTER the if (value.startsWith('$$')) return value.substring(1) escape (resolveWebhookSecretEnv takes a bare name — no escape, fail-closed), but the only escape test uses a non-secret name ('$$literal-token') and all new denylist tests use single-$ references. — Concrete cost: a future refactor consolidating the three duplicated denylist blocks can hoist the check above the escape with every existing test still green; after that, a channel config holding a literal value spelled $$QWEN_SERVER_TOKEN — a previously valid, explicitly-literal configuration — aborts at config-parse time instead of resolving to the literal $QWEN_SERVER_TOKEN.

Witness (probe this round): resolveEnvVars('$$QWEN_SERVER_TOKEN') returns '$QWEN_SERVER_TOKEN' at HEAD. Flip: hoisting a denylist check above the escape makes the probe FAIL while all 54 existing config-utils tests stay green (1 failed | 54 passed under mutation) — the unguarded fragility, demonstrated; mutation reverted, tree clean.

// in the new internal-secret denylist describe:
expect(resolveEnvVars('$$QWEN_SERVER_TOKEN')).toBe('$QWEN_SERVER_TOKEN');
// plus the parseChannelConfig equivalent through a credential field
中文说明

[Suggestion] R17-7:没有测试钉住 $$ 转义与内部 secret 拒绝名单的顺序。resolveEnvVarsresolveConfigEnvVar 中新增的拒绝检查刻意放在 if (value.startsWith('$$')) return value.substring(1) 转义之后(resolveWebhookSecretEnv 接收裸变量名——无转义、fail-closed),但唯一的转义测试用的是非 secret 名('$$literal-token'),所有新的拒绝名单测试都用单 $ 引用。——具体代价:未来把三处重复拒绝块合并的重构可以把检查提升到转义之前而所有现有测试仍绿;此后,字面值写作 $$QWEN_SERVER_TOKEN 的 channel 配置——此前合法的显式字面量配置——会在配置解析时中止,而不是解析为字面量 $QWEN_SERVER_TOKEN

见证(本轮探针):HEAD 上 resolveEnvVars('$$QWEN_SERVER_TOKEN') 返回 '$QWEN_SERVER_TOKEN'。翻转:把拒绝检查提升到转义之前会使探针失败,而现有 54 个 config-utils 测试全部仍绿(变异下 1 failed | 54 passed)——无防护的脆弱性已被演示;变异已还原,工作区干净。

修复:在新的 internal-secret denylist describe 中增加 expect(resolveEnvVars('$$QWEN_SERVER_TOKEN')).toBe('$QWEN_SERVER_TOKEN')(以及通过凭据字段的 parseChannelConfig 等价用例),钉住转义在两处都先于拒绝名单短路。

— qwen3.8-max via Qwen Code /review (v0.21.12)

throw new Error(
`Environment variable ${envName} is a Qwen-internal secret; internal secrets are never resolved into channel config (referenced as ${value})`,
);
}
const envValue = env[envName];
if (envValue === undefined) {
throw new Error(
Expand Down Expand Up @@ -76,6 +87,11 @@ function resolveConfigEnvVar(value: string, mode: EnvResolution): string {
if (value.startsWith('$$')) return value.substring(1);
if (mode === 'available' && value.startsWith('$')) {
const envName = value.substring(1);
if (isInternalSecretEnvVar(envName)) {
throw new Error(
`Environment variable ${envName} is a Qwen-internal secret; internal secrets are never resolved into channel config (referenced as ${value})`,
);
}
const envValue = process.env[envName];
if (envValue === undefined) {
throw new Error(
Expand Down Expand Up @@ -290,6 +306,11 @@ function resolveWebhookSecretEnv(
`Channel "${channelName}" field "${path}.secretEnv" must be an environment variable name or $-prefixed reference.`,
);
}
if (isInternalSecretEnvVar(envName)) {
throw new Error(
`Channel "${channelName}" field "${path}.secretEnv" references a Qwen-internal secret (${envName}); internal secrets are never resolved into channel config.`,
);
}
const envValue = env[envName];
if (envValue === undefined) {
throw new Error(
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3508,6 +3508,100 @@ describe('loadCliConfig allowPrivateNetworkHooks', () => {
});
});

describe('loadCliConfig allowedHttpHookUrls', () => {
const originalArgv = process.argv;

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});

afterEach(() => {
process.argv = originalArgv;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('should pass through security.allowedHttpHookUrls from settings', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {
security: {
allowedHttpHookUrls: ['https://hooks.corp.com/*'],
},
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getAllowedHttpHookUrls()).toEqual([
'https://hooks.corp.com/*',
]);
});

it('should read a malformed non-array allowedHttpHookUrls as an empty list instead of crashing', async () => {
process.argv = ['node', 'script.js'];
// A hand-edited settings file can hold a bare string here (settings
// are validated only as top-level JSON objects); it must be coerced
// before reaching the UrlValidator instead of aborting startup.
const settings: Settings = {
security: {
allowedHttpHookUrls: 'https://hooks.corp.com/*' as unknown as string[],
},
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getAllowedHttpHookUrls()).toEqual([]);
});

it('should warn that a malformed allowedHttpHookUrls leaves HTTP hooks unrestricted', async () => {
process.argv = ['node', 'script.js'];
// The coercion to [] reads as "allow all" in the UrlValidator; the
// user's attempted restriction vanishes, so it must be surfaced.
const settings: Settings = {
security: {
allowedHttpHookUrls: 'https://hooks.corp.com/*' as unknown as string[],
},
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getAllowedHttpHookUrls()).toEqual([]);
expect(
config.getWarnings().some((w) => w.includes('allowedHttpHookUrls')),
).toBe(true);
});

it('should not warn about allowedHttpHookUrls when the value is a valid list', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {
security: {
allowedHttpHookUrls: ['https://hooks.corp.com/*'],
},
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(
config.getWarnings().some((w) => w.includes('allowedHttpHookUrls')),
).toBe(false);
});

it('should drop non-string allowedHttpHookUrls entries', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {
security: {
allowedHttpHookUrls: [
42,
'https://hooks.corp.com/ci/*',
null,
] as unknown as string[],
},
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getAllowedHttpHookUrls()).toEqual([
'https://hooks.corp.com/ci/*',
]);
});
});

describe('loadCliConfig with includeDirectories', () => {
const originalArgv = process.argv;

Expand Down
25 changes: 21 additions & 4 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2328,10 +2328,27 @@ export async function loadCliConfig(
warnings: resolvedCliConfig.warnings,
bareMode,
safeMode,
allowedHttpHookUrls:
bareMode || safeMode
? []
: (settings.security?.allowedHttpHookUrls ?? []),
allowedHttpHookUrls: (() => {
if (bareMode || safeMode) {
return [];
}
// Settings files are validated only as top-level JSON objects, so a
// hand-edited file can put a bare string or non-string entries here;
// reduce it to a valid list before it reaches the UrlValidator, which
// maps over it and would abort startup on a non-array.
const hookUrls = settings.security?.allowedHttpHookUrls;
if (hookUrls !== undefined && !Array.isArray(hookUrls)) {
// The coercion to [] reads as "allow all" in the UrlValidator, so
// surface the lost restriction instead of silently starting
// unrestricted.
resolvedCliConfig.warnings.push(
'Warning: security.allowedHttpHookUrls is not a list and was ignored; HTTP hooks are unrestricted apart from SSRF protection.',
);
}
return Array.isArray(hookUrls)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R15-3: An array whose entries are ALL non-strings is silently coerced to [] — which the UrlValidator reads as "allow all" — without the warning the adjacent branch adds for the identical outcome. Array.isArray is true, so the warning branch above is skipped; the filter drops every entry; the user's attempted restriction vanishes with no startup warning (the diff's own rationale comment says to "surface the lost restriction instead of silently starting unrestricted").

Failure scenario: probe at this commit — settings { security: { allowedHttpHookUrls: [42, null] } }getAllowedHttpHookUrls() returned [] with zero warnings mentioning the field (expected 0 to be greater than 0); applying the implied fix flipped the probe and the suite stayed green. Boundary: mixed arrays keep their surviving strings; an explicitly empty [] stays warning-free by design ("If empty, all URLs are allowed").

      const filtered = Array.isArray(hookUrls)
        ? hookUrls.filter((entry): entry is string => typeof entry === 'string')
        : [];
      if (Array.isArray(hookUrls) && hookUrls.length > 0 && filtered.length === 0) {
        resolvedCliConfig.warnings.push(
          'Warning: security.allowedHttpHookUrls contains no string entries and was ignored; HTTP hooks are unrestricted apart from SSRF protection.',
        );
      }
      return filtered;
中文说明

[Suggestion] 全部条目都不是字符串的数组会被静默强转为 []——UrlValidator 将其读作"全部允许"——却没有相邻分支为同样结果添加的那条告警。Array.isArray 为 true,因此上方的告警分支被跳过;filter 丢弃所有条目;用户试图设置的限制在没有启动告警的情况下消失(diff 自身的注释正写着"surface the lost restriction instead of silently starting unrestricted")。

失败场景:已在本 commit 探针验证——settings { security: { allowedHttpHookUrls: [42, null] } }getAllowedHttpHookUrls() 返回 [] 且没有任何提及该字段的告警(expected 0 to be greater than 0);应用隐含修复后探针翻转、套件保持绿色。边界:混合数组保留存活的字符串;显式空数组 [] 按设计不告警("If empty, all URLs are allowed")。

— qwen3.8-max via Qwen Code /review (v0.21.6)

? hookUrls.filter((entry): entry is string => typeof entry === 'string')
: [];
Comment on lines +2339 to +2350

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R12-2: a malformed non-array allowedHttpHookUrls from a user/system-scope settings file is silently coerced to [], which the hook URL validator reads as "allow all" — the user's attempted restriction vanishes with no warning anywhere (getSettingsWarnings only covers workspace-scope values). — Concrete cost: a user hand-edits ~/.qwen/settings.json to "security": {"allowedHttpHookUrls": "https://hooks.corp.com/*"} (bare string instead of an array — settings files are validated only as top-level JSON objects, as this code's own comment notes); the value reaches loadCliConfig, is coerced to [], UrlValidator.isAllowed returns true for every URL, and the CLI starts with HTTP hooks unrestricted (apart from SSRF guards) while the user believes they are whitelisted to one host. Pre-PR the same typo surfaced loudly (TypeError in the UrlValidator constructor). The deliberate crash→coerce change answers "don't abort startup", but the silent allow-all outcome still deserves a surface. Reported independently by two reviewers.

// when the value is defined but not a valid string list, warn via the
// existing warnings channel (mirroring getSettingsWarnings' style):
const hookUrls = settings.security?.allowedHttpHookUrls;
if (hookUrls !== undefined && !Array.isArray(hookUrls)) {
  resolvedCliConfig.warnings.push(
    'security.allowedHttpHookUrls is not a valid list and was ignored; HTTP hooks are unrestricted apart from SSRF protection',
  );
}
中文说明

[Suggestion] user/system scope 设置文件中畸形的非数组 allowedHttpHookUrls 被静默强转为 [],而 hook URL 校验器把空列表读作"全部允许"——用户试图施加的限制凭空消失,且任何地方都没有告警(getSettingsWarnings 只覆盖 workspace scope 的值)。—— 具体代价:用户手工把 ~/.qwen/settings.json 编辑为 "security": {"allowedHttpHookUrls": "https://hooks.corp.com/*"}(裸字符串而非数组——settings 文件只做顶层 JSON 对象校验,本代码自己的注释也这么写);该值到达 loadCliConfig,被强转为 []UrlValidator.isAllowed 对每个 URL 都返回 true,CLI 正常启动且 HTTP hooks 不受限(仅剩 SSRF 防护),而用户以为自己已把白名单限定到一个主机。改动前同样的笔误会大声报错(UrlValidator 构造函数抛 TypeError)。刻意的"崩溃→强转"改动回应了"不要中止启动",但静默的全部允许结果仍应被呈现。两位评审者独立报告了此问题。修复:当值已定义但不是合法字符串列表时,通过既有 warnings 渠道告警(见上方代码块)。

— qwen3.8-max via Qwen Code /review (v0.21.5)

})(),
allowPrivateNetworkHooks:
bareMode || safeMode
? false
Comment on lines 2352 to 2354

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R15-10: The malformed-value hardening added four lines above covers only allowedHttpHookUrls; the adjacent allowPrivateNetworkHooks (next line) is still passed through raw, and every consumption site tests truthiness (!allowPrivateNetworkHosts && isBlockedAddress(...) in httpHookRunner.ts:66/92 and urlValidator.ts:126) — so any non-empty string silently ENABLES the private-network relaxation. Settings are env-interpolated whole before merging (resolveEnvVarsInObject runs on system/systemDefaults/user/workspace scopes), env values are always strings, and an unset variable leaves the literal $PLACEHOLDER — also truthy.

Failure scenario: probe at this commit — ALLOW_HOOKS_PRIVATE_IP=false with "security": { "allowPrivateNetworkHooks": "$ALLOW_HOOKS_PRIVATE_IP" }loadCliConfig returned 'false' (string, truthy), and HttpHookRunner([], 'false') POSTed to a private-range URL, while the boolean-false control arm was blocked by the SSRF guard. Changing the next line to === true flipped the probe. Not attacker-reachable (workspace scope is stripped) — the direction is admin-disables → silently enabled; the docs section this PR edits ("pair this flag with a whitelist") is exactly where an admin would env-reference the flag.

Suggested fix: coerce on the next line — : settings.security?.allowPrivateNetworkHooks === true, — and push a warning into resolvedCliConfig.warnings when the value is present but not a boolean (mirroring the array field's warning).

中文说明

[Suggestion] 上方四行新增的畸形值加固只覆盖了 allowedHttpHookUrls;相邻的 allowPrivateNetworkHooks(下一行)仍原样透传,而所有消费点都做真值判断(httpHookRunner.ts:66/92 与 urlValidator.ts:126 的 !allowPrivateNetworkHosts && isBlockedAddress(...))——因此任何非空字符串都会静默启用内网放行。settings 在合并前整体做环境变量插值(resolveEnvVarsInObject 作用于 system/systemDefaults/user/workspace 各作用域),环境变量值总是字符串,未设置的变量还会留下字面量 $PLACEHOLDER——同样是真值。

失败场景:已在本 commit 探针验证——ALLOW_HOOKS_PRIVATE_IP=false"security": { "allowPrivateNetworkHooks": "$ALLOW_HOOKS_PRIVATE_IP" }loadCliConfig 返回 'false'(字符串,真值),HttpHookRunner([], 'false') 成功 POST 到内网地址,而布尔 false 的对照分支被 SSRF 防护拦截。把下一行改为 === true 后探针翻转。攻击者不可达(workspace 作用域已被剥离)——方向是管理员关闭 → 被静默开启;本 PR 编辑的文档小节("pair this flag with a whitelist")恰是管理员会用环境变量引用该标志的地方。

修复建议:在下一行强转——: settings.security?.allowPrivateNetworkHooks === true,——并在值存在但不是布尔类型时向 resolvedCliConfig.warnings 推送告警(与数组字段的告警对称)。

— qwen3.8-max via Qwen Code /review (v0.21.6)

Expand Down
Loading
Loading