mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): require explicit trust for settings-file project hooks (#16196)
* fix(core): require explicit trust for settings-file project hooks A repository .qwen/settings.json SessionStart hook ran at startup with no trust prompt while the folder-trust feature was off by default: getProjectHooks() consulted isTrustedFolder(), which returns trusted when the feature is disabled. Gate it on isExplicitlyTrustedFolder() instead, matching the frontmatter-hook and LSP gates from #16182. Update the hooks docs, and grant explicit trust in hook test fixtures. Refs #14746 Co-authored-by: Qwen-Coder <[email protected]> * style(core): use Array<T> for the tuple-array cast Co-authored-by: Qwen-Coder <[email protected]> * test(integration): grant explicit trust in ACP parity fixtures The ACP parity rig spawns the CLI with its own QWEN_HOME, so the trust granted by TestRig.setup() never reaches those sessions. With project hooks now requiring explicit folder trust, the UserPromptSubmit hook scenarios silently lost their hooks. Seed the scenario home with folder trust enabled and the rig directory trusted, matching the tool-hook suites. Co-authored-by: Qwen-Coder <[email protected]> * fix(core): align project-hook trust with the interactive boundary Follow the Claude Code model: interactive sessions keep the explicit folder-trust gate, while non-interactive invocations (-p, piped stdin, ACP/SDK embeddings) treat trust as implicit — the caller chose the directory and there is no prompt surface. This keeps platform embedders working without trust-file migration. Surface a startup warning when interactive project hooks are withheld so the gate is not silent, and document the headless contract. Revert the integration-fixture trust grants that the headless exemption makes unnecessary. Refs #14746 Co-authored-by: Qwen-Coder <[email protected]> * test(integration): drop ACP parity trust seeding made moot by headless exemption The parity rig spawns non-interactive ACP sessions, which now treat trust as implicit, so the seeded trustedFolders.json only perturbed the request-snapshot comparison (an extra tmp/ tree under .qwen-home). Restore the fixture to its main-branch shape; all 23 parity cases pass. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): tolerate partial settings shapes in the project-hook warning The startup warning read settings.workspace.settings.hooks without optional chaining; AppContainer tests render with a partial LoadedSettings mock, and a missing workspace scope crashed the effect before config initialization completed. Guard the lookup; all 272 AppContainer tests pass. Co-authored-by: Qwen-Coder <[email protected]> * docs(hooks): resolve trust-gate wording contradictions (R1-5) Troubleshooting step 3 still stated the legacy trusted-folder rule and the restart list omitted security.folderTrust and persisted trust decisions. Align both with the interactive explicit-trust gate and the headless implicit-trust contract, and mention the withheld-hooks startup warning and browser state. Co-authored-by: Qwen-Coder <[email protected]> * test(integration): drop dead ACP trust seeding in the tool-hook suites The per-session trustedFolders.json written beside the QWEN_HOME override is never read: getTrustedFoldersPath() prefers the inherited QWEN_CODE_TRUSTED_FOLDERS_PATH that TestRig.setup() stubs. Both blocks sit in front of an --acp spawn, and ACP sessions are non-interactive, so the headless exemption already loads project hooks without any trust grant. Remove the writes and the now-unused realpathSync import, restoring both suites to their base shape. Refs #14746 * test(cli): pin the folderTrust derivation behind the project-hook gate The interactive gate is covered by the Config table, but nothing pinned settings.security.folderTrust.enabled -> Config.folderTrust. Assert that loadCliConfig derives it and defaults it to false, so a derivation flipping to true cannot silently arm repo-supplied hooks. Verified the case goes red when the default is mutated to true. Refs #14746 * docs(hooks): align trust-boundary descriptions with implementation Remove the unimplemented claim that withheld project hooks appear in the hook browser. Clarify that loop.md retains the legacy folder-trust predicate while executable project hooks use explicit interactive trust. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): keep folder trust out of workspace reach The project-hook gate reads security.folderTrust.enabled from merged settings, so a repository could enable the very flag that authorizes its own hooks and then reach any pre-existing TRUST_FOLDER or TRUST_PARENT rule for the directory. Restrict the key to the operator scopes, beside allowPrivateNetworkHooks, and pin both directions: a workspace can neither enable the gate nor disarm a user's setting. Also close the smaller review notes. The startup warning no longer blames trust when disableAllHooks removed the hook system entirely, and it now has coverage for the warned and both silent branches. Every hook fixture ran pre-trusted, so add an interactive integration case that declines trust and asserts the hooks never fire, the warning names the remedy, and the tool result still reaches the model unmarked. Drop the duplicated node:fs import and correct the docs claim that IDE trust decisions apply live to settings-file hooks. Refs #14746 Co-authored-by: Qwen-Coder <[email protected]> * test(cli): align the daemon trust-grant case with the workspace restriction security.folderTrust is now stripped from workspace scope, so a grant on a workspace that only self-enables trust reports the disabled state instead of refusing an ineffective grant. Keep the alias DO_NOT_TRUST fixture: if the restriction regresses, the grant throws again and this case goes red (mutation-verified). Fixes the Test (ubuntu-latest) failure ona89935fa. * test(cli): follow operator trust in the ancestor-trust case R1-22 asserted that a workspace folderTrust value reaches the merged settings, which is the behavior the workspace restriction removes. Drive the case from the operator's flag instead: the merged value must survive the repo's opposing declaration, and the ancestor provider must follow the operator — denying every ancestor on the target's DO_NOT_TRUST rule when the feature is on, falling back to directory safety when it is off. Mutation-verified: dropping the restriction turns both cases red. Fixes the Test (ubuntu-latest) failure on6851f531. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
e77f73e973
commit
35aa4b76ae
16 files changed
+404
-40
No files matched your search
@@ -36,7 +36,7 @@ The browser never edits your configuration: to add, change or remove a hook, edi
|
||||
|
||||
Reloading requires this explicit menu-open action: saving a file, pulling changes or switching branches does not automatically arm new hook commands. The non-interactive `/hooks list` only displays the registry currently loaded by that process; it does not reload settings. In an interactive terminal, `/hooks list` opens the same menu as `/hooks`.
|
||||
|
||||
This reload covers hook definitions, not hook controls or HTTP security settings. Changes to `disableAllHooks`, `stopHookBlockingCap`, `security.allowedHttpHookUrls` and `security.allowPrivateNetworkHooks` still require a restart. Hooks registered at runtime by skills or the SDK are not affected. Project hooks load only in a trusted folder. When hooks are turned off by `disableAllHooks`, `--safe-mode` or `--bare`, none load and the browser says so at the top.
|
||||
This reload covers hook definitions, not hook controls or HTTP security settings. Changes to `disableAllHooks`, `stopHookBlockingCap`, `security.allowedHttpHookUrls`, `security.allowPrivateNetworkHooks` and `security.folderTrust` (including trust-store entries) still require a restart. `security.folderTrust` is read from user, system and system-default settings only; a workspace file cannot enable it. Hooks registered at runtime by skills or the SDK are not affected. In interactive sessions, project hooks from a project's `.qwen/settings.json` load only with **explicit folder trust**: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE. A disabled folder-trust feature does not authorize them; when they are withheld the session shows a startup warning. Settings-file hooks are registered once at startup, so an IDE trust change does not retroactively arm or silence them — reopen the `/hooks` menu or restart to apply it. In non-interactive sessions (`--prompt`/`-p`, piped stdin, ACP/SDK embeddings) folder trust is implicit — the caller chose the directory and there is no prompt surface — so project hooks load as before; only run non-interactive sessions in directories you trust. When hooks are turned off by `disableAllHooks`, `--safe-mode` or `--bare`, none load and the browser says so at the top.
|
||||
|
||||
### Agent frontmatter scope
|
||||
|
||||
@@ -1626,7 +1626,8 @@ fi
|
||||
### Security Model
|
||||
|
||||
- Hooks run in the user's environment with user privileges
|
||||
- Project-level hooks require trusted folder status
|
||||
- Interactive sessions: project-level hooks require explicit folder trust (enable `security.folderTrust.enabled` and trust the folder, or grant IDE workspace trust)
|
||||
- Non-interactive sessions (`-p`, piped stdin, ACP/SDK): folder trust is implicit; only use directories you trust
|
||||
- Timeouts prevent hanging hooks (default: 60 seconds for command hooks)
|
||||
|
||||
## Best Practices
|
||||
@@ -1788,7 +1789,7 @@ sys.exit(0)
|
||||
|
||||
1. **Event name.** The key must be one of the events in [Hook Events](#hook-events), spelled exactly, including case. An unknown event name is skipped with the warning `Invalid hook event name`.
|
||||
2. **Matcher.** Tool events match the runtime tool id, such as `run_shell_command` or `write_file`, or an accepted alias such as Claude Code's `Bash` or `Write` (see [Matcher Patterns](#matcher-patterns)). On events without matcher support, `matcher` is ignored.
|
||||
3. **Folder trust.** Hooks in a project's `.qwen/settings.json` load only when the folder is trusted. User hooks load regardless of folder trust.
|
||||
3. **Folder trust.** In interactive sessions, hooks in a project's `.qwen/settings.json` load only with **explicit folder trust**: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE. A disabled folder-trust feature does not authorize them (see [Trusted Folders](../configuration/trusted-folders.md)). In non-interactive sessions (`-p`, piped stdin, ACP/SDK embeddings) trust is implicit. User hooks load regardless of folder trust.
|
||||
4. **Hooks disabled.** `"disableAllHooks": true`, `--safe-mode` (or `QWEN_CODE_SAFE_MODE`) and `--bare` (or `QWEN_CODE_SIMPLE`) turn off all hooks, including user hooks.
|
||||
5. **Debug log.** Start Qwen Code with `--debug`, or set `QWEN_DEBUG_LOG_FILE=1`, then read the session log at `~/.qwen/debug/latest` (under `$QWEN_RUNTIME_DIR` when that is set). Each line is tagged with a namespace:
|
||||
- `[HOOK_REGISTRY]`: `Hook registry initialized with N hook entries`, and hook definitions discarded as invalid
|
||||
@@ -1799,7 +1800,7 @@ sys.exit(0)
|
||||
|
||||
Prompt-hook inputs can be written to the session debug log, so apply appropriate access and retention controls.
|
||||
|
||||
6. **Edited during the session.** Hooks are read when the session starts. After editing hook definitions, open the interactive `/hooks` menu once to reload them, or restart Qwen Code. Changes to hook controls and HTTP security settings require a restart.
|
||||
6. **Edited during the session.** Hooks are read when the session starts. After editing hook definitions, open the interactive `/hooks` menu once to reload them, or restart Qwen Code. Changes to hook controls, HTTP security settings and `security.folderTrust` (including trust-store entries) require a restart. Settings-file project hooks are also registered once, so a later IDE trust grant or revocation applies only after that reload or restart.
|
||||
|
||||
### Other checks
|
||||
|
||||
|
||||
@@ -183,6 +183,22 @@ describe('tool hook additionalContext delivery', () => {
|
||||
expect(count(result, marker('PreToolUse', 'call_allow'))).toBe(1);
|
||||
});
|
||||
|
||||
it('headless: runs project hooks with the trust feature disabled (implicit trust)', async () => {
|
||||
const server = await setup(
|
||||
'hook context headless implicit trust',
|
||||
'read_file',
|
||||
'note.txt',
|
||||
'call_implicit_trust',
|
||||
undefined,
|
||||
{ security: { folderTrust: { enabled: false } } },
|
||||
);
|
||||
await rig.run('read the note', ...fakeModelLaunchArgs(server));
|
||||
|
||||
expect(hookHits()).toEqual([
|
||||
{ event: 'PreToolUse', call: 'call_implicit_trust' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('headless: delivers PreToolUse context with a denied result', async () => {
|
||||
const server = await setup(
|
||||
'hook context headless deny',
|
||||
@@ -254,6 +270,63 @@ describe('tool hook additionalContext delivery', () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('interactive: withholds project hooks in an untrusted folder and says why', async () => {
|
||||
const server = await setup(
|
||||
'hook context interactive untrusted',
|
||||
'read_file',
|
||||
'note.txt',
|
||||
'call_untrusted',
|
||||
);
|
||||
// Every other fixture runs pre-trusted, so drop the rig's trust grant to
|
||||
// reach the withheld branch the gate exists for.
|
||||
writeFileSync(
|
||||
join(rig.testDir!, '.qwen-home', 'settings.json'),
|
||||
JSON.stringify({ security: { folderTrust: { enabled: true } } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(rig.testDir!, '.qwen-home', 'trustedFolders.json'),
|
||||
JSON.stringify({}),
|
||||
);
|
||||
const { ptyProcess } = rig.runInteractive(...fakeModelLaunchArgs(server));
|
||||
let output = '';
|
||||
ptyProcess.onData((data) => {
|
||||
output += data;
|
||||
});
|
||||
const waitFor = makeWaitFor(rig, () => output);
|
||||
// The pane wraps the warning, so match on a fragment inside one line.
|
||||
await waitFor('the folder-trust dialog', () =>
|
||||
output.includes('Do you trust this folder?'),
|
||||
);
|
||||
// The third option declines trust; esc alone leaves the dialog open.
|
||||
ptyProcess.write('\u001b[B');
|
||||
ptyProcess.write('\u001b[B');
|
||||
ptyProcess.write('\r');
|
||||
await waitFor('the composer after declining trust', () =>
|
||||
output.includes('Type your message'),
|
||||
);
|
||||
await waitFor('the withheld-hooks warning', () =>
|
||||
output.includes('Project hooks in .qwen/settings.json were not loaded'),
|
||||
);
|
||||
|
||||
const prompt = 'read the note';
|
||||
ptyProcess.write(prompt);
|
||||
await waitFor('the prompt to echo', () => output.includes(prompt));
|
||||
ptyProcess.write('\r');
|
||||
await waitFor('the follow-up model request', () =>
|
||||
server.requests.some(
|
||||
(request) =>
|
||||
request.body['stream'] === true &&
|
||||
JSON.stringify(request.body['messages']).includes('"role":"tool"'),
|
||||
),
|
||||
);
|
||||
await exitInteractive(ptyProcess, waitFor, () => output);
|
||||
|
||||
expect(hookHits()).toEqual([]);
|
||||
const result = toolResultFor(server, 'call_untrusted');
|
||||
expect(result).toContain('hello from note');
|
||||
expect(count(result, marker('PreToolUse', 'call_untrusted'))).toBe(0);
|
||||
}, 360_000);
|
||||
|
||||
it('interactive: delivers the first PreToolUse ask context once after approval', async () => {
|
||||
const server = await setup(
|
||||
'hook context interactive ask',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -278,6 +278,30 @@ export class TestRig {
|
||||
join(qwenDir, 'settings.json'),
|
||||
JSON.stringify(settings, null, 2),
|
||||
);
|
||||
|
||||
// Hooks declared in this project settings file now require explicit
|
||||
// folder trust. Grant the rig directory in an isolated trust store so
|
||||
// hook fixtures keep executing; tests about untrusted behavior override
|
||||
// QWEN_CODE_TRUSTED_FOLDERS_PATH themselves.
|
||||
const qwenHome = join(this.testDir, '.qwen-home');
|
||||
mkdirSync(qwenHome, { recursive: true });
|
||||
const userSettingsPath = join(qwenHome, 'settings.json');
|
||||
if (!fs.existsSync(userSettingsPath)) {
|
||||
writeFileSync(
|
||||
userSettingsPath,
|
||||
JSON.stringify({ security: { folderTrust: { enabled: true } } }),
|
||||
);
|
||||
}
|
||||
const trustStorePath = join(qwenHome, 'trustedFolders.json');
|
||||
if (!fs.existsSync(trustStorePath)) {
|
||||
writeFileSync(
|
||||
trustStorePath,
|
||||
JSON.stringify({ [realpathSync(this.testDir)]: 'TRUST_FOLDER' }),
|
||||
);
|
||||
}
|
||||
vi.stubEnv('QWEN_HOME', qwenHome);
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', qwenHome);
|
||||
vi.stubEnv('QWEN_CODE_TRUSTED_FOLDERS_PATH', trustStorePath);
|
||||
}
|
||||
|
||||
createFile(fileName: string, content: string) {
|
||||
|
||||
@@ -12775,8 +12775,10 @@ export class Session implements SessionContext {
|
||||
homeDir: homeConfineRoot,
|
||||
homeQwenDir,
|
||||
// The project `.qwen/loop.md` is repo-controlled, so an untrusted folder
|
||||
// must not read it and feed it to the model (mirrors getProjectHooks()'s
|
||||
// trust gate). The home/global `~/.qwen/loop.md` is user-owned and stays
|
||||
// must not read it and feed it to the model. This is the legacy
|
||||
// folder-trust check: loop.md is prompt content, while the stricter
|
||||
// explicit-trust gate (getProjectHooks()) covers executable repo
|
||||
// content. The home/global `~/.qwen/loop.md` is user-owned and stays
|
||||
// allowed. Pass a getter, not a snapshot: isTrustedFolder() can flip
|
||||
// mid-session on an IDE workspace-trust update, and the resolver outlives
|
||||
// a single tick — re-read it on every resolve() so a trusted→untrusted
|
||||
|
||||
@@ -1548,6 +1548,21 @@ describe('loadCliConfig', () => {
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives folderTrust from settings and defaults it to false', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
// The interactive project-hook gate reads Config.folderTrust, so a
|
||||
// derivation defaulting it to true would silently arm repo-supplied hooks.
|
||||
await loadCliConfig({}, argv);
|
||||
expect(mockConfigConstructorParams.mock.lastCall?.[0].folderTrust).toBe(
|
||||
false,
|
||||
);
|
||||
await loadCliConfig({ security: { folderTrust: { enabled: true } } }, argv);
|
||||
expect(mockConfigConstructorParams.mock.lastCall?.[0].folderTrust).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['bare', 'safe', 'ssh', 'agent-host', 'provisional'] as const)(
|
||||
'H03 strips a supplied ancestor provider from %s sessions',
|
||||
async (kind) => {
|
||||
|
||||
@@ -1499,7 +1499,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use folderTrust from workspace settings when trusted', () => {
|
||||
it('should ignore workspace folderTrust and keep the user value', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
security: {
|
||||
@@ -1511,7 +1511,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const workspaceSettingsContent = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false, // This should be used
|
||||
enabled: false, // A repository must not disarm the gate.
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1532,7 +1532,27 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not let a workspace enable folderTrust for itself', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
security: { folderTrust: { enabled: true } },
|
||||
context: { fileName: 'WORKSPACE.md' },
|
||||
});
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Otherwise a repo could arm the gate that authorizes its own hooks and
|
||||
// reach any pre-existing TRUST_FOLDER/TRUST_PARENT rule for the directory.
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBeUndefined();
|
||||
expect(settings.merged.context?.fileName).toBe('WORKSPACE.md');
|
||||
});
|
||||
|
||||
it('should use system folderTrust over user setting', () => {
|
||||
@@ -4101,14 +4121,14 @@ describe('Settings Loading and Merging', () => {
|
||||
[MOCK_WORKSPACE_SETTINGS_PATH]: {
|
||||
security: {
|
||||
allowedHttpHookUrls: ['*'],
|
||||
folderTrust: { enabled: true },
|
||||
dnsResolutionOrder: 'ipv4first',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.allowedHttpHookUrls).toEqual(USER_LIST);
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(true);
|
||||
expect(settings.merged.security?.dnsResolutionOrder).toBe('ipv4first');
|
||||
});
|
||||
|
||||
it('drops the workspace whitelist entirely when the folder is untrusted', () => {
|
||||
|
||||
@@ -283,6 +283,8 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [
|
||||
{ section: 'tools', key: 'executionSandbox' },
|
||||
{ section: 'tools', key: 'workflowsEnabled' },
|
||||
{ section: 'security', key: 'allowPrivateNetworkHooks' },
|
||||
// A repository must not switch on the gate that authorizes its own hooks.
|
||||
{ section: 'security', key: 'folderTrust' },
|
||||
{ section: 'security', key: 'allowedInsecureVoiceBaseUrls' },
|
||||
{ section: 'goals', key: 'modelProposed' },
|
||||
{ section: 'outboundCorrelation', key: 'allowDynamicHeaderValues' },
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
LoadedSettings,
|
||||
type Settings,
|
||||
} from './settings.js';
|
||||
import { isWorkflowAncestorDirectorySafe } from './workflow-ancestor-directory-safety.js';
|
||||
|
||||
describe('local workflow ancestor trust', () => {
|
||||
let directory: string;
|
||||
@@ -163,23 +164,26 @@ describe('local workflow ancestor trust', () => {
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'R1-22 uses operator trust when workspace overrides enabled=%s',
|
||||
'R1-22 uses operator trust when workspace declares enabled=%s',
|
||||
async (workspaceEnabled) => {
|
||||
const file = (settings: Settings) => ({
|
||||
settings,
|
||||
originalSettings: settings,
|
||||
path: 'fixture',
|
||||
});
|
||||
const operatorEnabled = !workspaceEnabled;
|
||||
const loaded = new LoadedSettings(
|
||||
file({}),
|
||||
file({}),
|
||||
file({ security: { folderTrust: { enabled: !workspaceEnabled } } }),
|
||||
file({ security: { folderTrust: { enabled: operatorEnabled } } }),
|
||||
file({ security: { folderTrust: { enabled: workspaceEnabled } } }),
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
// The workspace value is restricted, so the operator's flag survives
|
||||
// the merge and the ancestor provider must follow it, not the repo's.
|
||||
expect(loaded.merged.security?.folderTrust?.enabled).toBe(
|
||||
workspaceEnabled,
|
||||
operatorEnabled,
|
||||
);
|
||||
writeRules({
|
||||
[repo]: TrustLevel.TRUST_FOLDER,
|
||||
@@ -188,10 +192,14 @@ describe('local workflow ancestor trust', () => {
|
||||
const provider = createLocalWorkflowAncestorTrustProvider(
|
||||
getOperatorTrustSettings(loaded),
|
||||
);
|
||||
await expect(provider([packages, repo], target)).resolves.toEqual([
|
||||
workspaceEnabled,
|
||||
workspaceEnabled,
|
||||
]);
|
||||
// With the operator's feature on, the target's DO_NOT_TRUST rule denies
|
||||
// every ancestor. With it off, the provider falls back to directory
|
||||
// safety and never consults the rules — the repo's flag changes neither.
|
||||
await expect(provider([packages, repo], target)).resolves.toEqual(
|
||||
operatorEnabled
|
||||
? [false, false]
|
||||
: [packages, repo].map(isWorkflowAncestorDirectorySafe),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -733,7 +733,7 @@ describe('createDaemonWorkspaceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('grantWorkspaceTrust refuses a reported deny when only workspace settings enable trust', async () => {
|
||||
it('grantWorkspaceTrust ignores workspace settings that try to enable trust', async () => {
|
||||
await withIsolatedWorkspace(async ({ home, workspace }) => {
|
||||
await writeJson(path.join(workspace, '.qwen', 'settings.json'), {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
@@ -753,14 +753,16 @@ describe('createDaemonWorkspaceService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(svc.grantWorkspaceTrust(makeCtx())).rejects.toThrow(
|
||||
WorkspaceTrustGrantIneffectiveError,
|
||||
// The workspace flag is stripped from the merged settings, so the
|
||||
// feature stays disabled and the grant reports the disabled state
|
||||
// instead of refusing an ineffective grant. If the restriction ever
|
||||
// regresses, the alias DO_NOT_TRUST wins again and the grant throws.
|
||||
await expect(svc.grantWorkspaceTrust(makeCtx())).resolves.toMatchObject(
|
||||
{
|
||||
folderTrustEnabled: false,
|
||||
effective: { source: 'disabled', state: 'trusted' },
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
svc.getWorkspaceTrustStatus(makeCtx()),
|
||||
).resolves.toMatchObject({
|
||||
effective: { state: 'untrusted', source: 'file' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1558,6 +1558,101 @@ describe('AppContainer State Management', () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the folder is explicitly trusted', { trusted: true }],
|
||||
['every hook is disabled', { trusted: false, disableAllHooks: true }],
|
||||
] as Array<[string, { trusted: boolean; disableAllHooks?: boolean }]>)(
|
||||
'does not warn about withheld project hooks when %s',
|
||||
async (_, state) => {
|
||||
const addItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
truncateToItem: vi.fn(),
|
||||
});
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isExplicitlyTrustedFolder').mockReturnValue(
|
||||
state.trusted,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getDisableAllHooks').mockReturnValue(
|
||||
state.disableAllHooks ?? false,
|
||||
);
|
||||
const settings = {
|
||||
...mockSettings,
|
||||
workspace: { settings: { hooks: { SessionStart: [] } } },
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={settings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(addItem).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'Project hooks in .qwen/settings.json',
|
||||
),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('warns once when an untrusted interactive session withholds project hooks', async () => {
|
||||
const addItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
truncateToItem: vi.fn(),
|
||||
});
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isExplicitlyTrustedFolder').mockReturnValue(false);
|
||||
vi.spyOn(mockConfig, 'getDisableAllHooks').mockReturnValue(false);
|
||||
const settings = {
|
||||
...mockSettings,
|
||||
workspace: {
|
||||
settings: {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: 'echo x' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={settings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.WARNING,
|
||||
text: expect.stringContaining('Project hooks in .qwen/settings.json'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders with startup warnings', () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
|
||||
|
||||
@@ -1977,6 +1977,40 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
}, [addHistoryItem, config]);
|
||||
|
||||
useEffect(() => {
|
||||
// Surface the fail-closed project-hook gate instead of letting hooks
|
||||
// stop silently after the explicit-trust requirement.
|
||||
if (
|
||||
!config.isInteractive() ||
|
||||
config.isExplicitlyTrustedFolder() ||
|
||||
config.getBareMode() ||
|
||||
config.isSafeMode() ||
|
||||
// With every hook disabled the trust gate is not why they are absent.
|
||||
config.getDisableAllHooks()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const projectHooks = settings.workspace?.settings?.hooks;
|
||||
if (
|
||||
!projectHooks ||
|
||||
typeof projectHooks !== 'object' ||
|
||||
Object.keys(projectHooks).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
addHistoryItem(
|
||||
{
|
||||
type: MessageType.WARNING,
|
||||
text: t(
|
||||
'Project hooks in .qwen/settings.json were not loaded: this folder is not explicitly trusted. Enable security.folderTrust.enabled and trust the folder to run them.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
// Startup notice only; later trust changes take effect on restart/reload.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
const clearPendingStateRef = useRef<() => void>(() => {});
|
||||
const clearPendingStateFromRef = useCallback(
|
||||
() => clearPendingStateRef.current(),
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('/hooks reload with real settings files', () => {
|
||||
settingsFile(path.join(directory, 'defaults.json'), {}),
|
||||
settingsFile(path.join(directory, 'user/settings.json'), {
|
||||
hooks: { Stop: hook('echo user') },
|
||||
security: { folderTrust: { enabled: true } },
|
||||
}),
|
||||
settingsFile(path.join(repo, '.qwen/settings.json'), {
|
||||
hooks: { PreToolUse: hook('echo project') },
|
||||
@@ -85,6 +86,7 @@ describe('/hooks reload with real settings files', () => {
|
||||
targetDir: cwd,
|
||||
cwd,
|
||||
debugMode: false,
|
||||
folderTrust: true,
|
||||
trustedFolder: trusted,
|
||||
usageStatisticsEnabled: false,
|
||||
...resolveHookSettingsForConfig(
|
||||
|
||||
@@ -1239,7 +1239,7 @@ describe('Server Config (config.ts)', () => {
|
||||
],
|
||||
[
|
||||
'replaces project hooks without leaking them into user hooks',
|
||||
{},
|
||||
{ interactive: true, folderTrust: true, trustedFolder: true },
|
||||
{ projectHooks },
|
||||
undefined,
|
||||
projectHooks,
|
||||
@@ -1271,9 +1271,13 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
|
||||
it.each([false, undefined])(
|
||||
'keeps the project hook gate when folder trust is %s',
|
||||
'keeps the interactive project hook gate when folder trust is %s',
|
||||
(trustedFolder) => {
|
||||
const config = makeConfig({ folderTrust: true, trustedFolder });
|
||||
const config = makeConfig({
|
||||
interactive: true,
|
||||
folderTrust: true,
|
||||
trustedFolder,
|
||||
});
|
||||
|
||||
config.setHooksFromSettings({ userHooks, projectHooks });
|
||||
|
||||
@@ -1294,23 +1298,24 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
describe('per-scope hooks and the legacy merged fallback', () => {
|
||||
const mergedHooks = { ...systemHooks, ...userHooks, ...projectHooks };
|
||||
const explicitTrust = { folderTrust: true, trustedFolder: true };
|
||||
|
||||
it.each([
|
||||
[
|
||||
'does not read the merged hooks as project hooks when only user hooks are supplied',
|
||||
{ userHooks },
|
||||
{ ...explicitTrust, userHooks },
|
||||
userHooks,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'does not read the merged hooks as user hooks when only project hooks are supplied',
|
||||
{ projectHooks },
|
||||
{ ...explicitTrust, projectHooks },
|
||||
undefined,
|
||||
projectHooks,
|
||||
],
|
||||
[
|
||||
'still serves the merged hooks as user and project hooks when no scope is supplied',
|
||||
{},
|
||||
explicitTrust,
|
||||
mergedHooks,
|
||||
mergedHooks,
|
||||
],
|
||||
@@ -1392,7 +1397,11 @@ describe('Server Config (config.ts)', () => {
|
||||
// A Config built from merged settings alone still registers the hook
|
||||
// under both the user and project sources. The planner is what keeps
|
||||
// that from running it twice.
|
||||
const registry = await registryFor({ hooks: lintHook });
|
||||
const registry = await registryFor({
|
||||
folderTrust: true,
|
||||
trustedFolder: true,
|
||||
hooks: lintHook,
|
||||
});
|
||||
expect(registry.getAllHooks().map(({ source }) => source)).toEqual([
|
||||
'user',
|
||||
'project',
|
||||
@@ -1409,6 +1418,7 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
it('loads system hooks in an untrusted folder, where project hooks are withheld', () => {
|
||||
const config = makeConfig({
|
||||
interactive: true,
|
||||
trustedFolder: false,
|
||||
systemHooks,
|
||||
projectHooks,
|
||||
@@ -1417,6 +1427,74 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config.getSystemHooks()).toBe(systemHooks);
|
||||
expect(config.getProjectHooks()).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the trust feature is off', {}, false],
|
||||
[
|
||||
'the feature is on but the folder is undecided',
|
||||
{ folderTrust: true },
|
||||
false,
|
||||
],
|
||||
[
|
||||
'the feature is on and the folder is explicitly trusted',
|
||||
{ folderTrust: true, trustedFolder: true },
|
||||
true,
|
||||
],
|
||||
] as Array<[string, Partial<ConfigParameters>, boolean]>)(
|
||||
'gates interactive settings-file project hooks when %s',
|
||||
(_label, trust, explicit) => {
|
||||
const config = makeConfig({
|
||||
interactive: true,
|
||||
...trust,
|
||||
projectHooks,
|
||||
});
|
||||
|
||||
expect(config.getProjectHooks()).toEqual(
|
||||
explicit ? projectHooks : undefined,
|
||||
);
|
||||
expect(config.getUserHooks()).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['the trust feature is off', {}],
|
||||
['the feature is on but the folder is undecided', { folderTrust: true }],
|
||||
[
|
||||
'the folder is explicitly untrusted',
|
||||
{ folderTrust: true, trustedFolder: false },
|
||||
],
|
||||
] as Array<[string, Partial<ConfigParameters>]>)(
|
||||
'loads project hooks in non-interactive sessions when %s (implicit trust)',
|
||||
(_label, trust) => {
|
||||
const config = makeConfig({
|
||||
interactive: false,
|
||||
...trust,
|
||||
projectHooks,
|
||||
});
|
||||
|
||||
expect(config.getProjectHooks()).toEqual(projectHooks);
|
||||
},
|
||||
);
|
||||
|
||||
it('still withholds project hooks in non-interactive safe and bare modes', () => {
|
||||
expect(
|
||||
makeConfig({ safeMode: true, projectHooks }).getProjectHooks(),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
makeConfig({ bareMode: true, projectHooks }).getProjectHooks(),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads project hooks with IDE-granted trust even while the feature is off', () => {
|
||||
ideContextStore.clear();
|
||||
ideContextStore.set({ workspaceState: { isTrusted: true } });
|
||||
try {
|
||||
const config = makeConfig({ interactive: true, projectHooks });
|
||||
expect(config.getProjectHooks()).toEqual(projectHooks);
|
||||
} finally {
|
||||
ideContextStore.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMessageBusChange', () => {
|
||||
|
||||
@@ -11760,15 +11760,21 @@ export class Config {
|
||||
|
||||
/**
|
||||
* Get project-level hooks configuration.
|
||||
* Returns hooks from workspace settings, only in trusted folders.
|
||||
* Returns hooks from workspace settings, only in explicitly trusted folders.
|
||||
* Used by HookRegistry to load project-specific hooks with proper source attribution.
|
||||
*/
|
||||
getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
|
||||
if (this.getBareMode() || this.isSafeMode()) {
|
||||
return undefined;
|
||||
}
|
||||
// Only return project hooks if workspace is trusted
|
||||
if (!this.isTrustedFolder()) {
|
||||
// Interactive sessions need explicit trust for repo-supplied hooks: a
|
||||
// disabled folder-trust feature must not silently authorize them.
|
||||
// Non-interactive invocations (`-p`, piped stdin, ACP/SDK embeddings)
|
||||
// treat trust as implicit — the caller chose the directory and there is
|
||||
// no prompt surface; only run them in directories you trust. An explicit
|
||||
// refusal is already enforced upstream: an untrusted workspace
|
||||
// contributes no settings, so it supplies no project hooks.
|
||||
if (this.isInteractive() && !this.isExplicitlyTrustedFolder()) {
|
||||
return undefined;
|
||||
}
|
||||
// The legacy merged `hooks` stands in only when no scope was supplied.
|
||||
|
||||
@@ -308,7 +308,7 @@ export class HookRegistry {
|
||||
this.processHooksConfiguration(userHooks, HooksConfigSource.User);
|
||||
}
|
||||
|
||||
// Load project hooks (only in trusted folders)
|
||||
// Load project hooks (only in explicitly trusted folders)
|
||||
// The config.getProjectHooks() already checks trust status internally
|
||||
const projectHooks = this.config.getProjectHooks();
|
||||
if (projectHooks) {
|
||||
|
||||
@@ -49,8 +49,10 @@ export interface ReadLoopTaskFileOptions {
|
||||
/**
|
||||
* When false, the project `.qwen/loop.md` candidate is skipped entirely — it
|
||||
* is repo-controlled, so an untrusted workspace must not read it and feed it
|
||||
* to the model (mirrors the folder-trust gate on project hooks). The
|
||||
* home/global `~/.qwen/loop.md` is user-owned and always allowed.
|
||||
* to the model. Callers pass the legacy folder-trust check: loop.md is prompt
|
||||
* content, while the stricter explicit-trust gate covers executable repo
|
||||
* content such as project hooks. The home/global `~/.qwen/loop.md` is
|
||||
* user-owned and always allowed.
|
||||
*
|
||||
* Defaults to false (fail-secure): this function is re-exported from the core
|
||||
* barrel, so a caller that omits the option must NOT silently read an
|
||||
|
||||
Reference in new issue
Block a user