fix(core): Stop the Python import guidance pointing at deployment config
Some checks failed
CI: Python / Checks (push) Has been cancelled

On managed deployments the import allowlist is not the user's to change, so an
agent that names `N8N_RUNNERS_STDLIB_ALLOW` or tells them their setting is
invalid only produces a support request about something nobody will change for
them. Cloud also runs task runners in external mode, where n8n cannot see the
runner's configuration at all, so that guidance was doubly unfounded there.

Every agent- and user-facing surface now states the capability and stops: assume
no imports, write import-free Python, or use JavaScript. Environment variables
survive only in code comments and the config class, where operators read them.

The external-mode and rejected-allowlist cases now share one message. They led to
the same action already, and the separate wording existed only to name the
setting behind each.

Tests pin the rule across every policy shape rather than the phrasing, so a
future edit cannot quietly reintroduce a config recommendation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour 2026-08-27 15:47:56 +02:00
parent b11d591f1f
commit ff8bc0aa7b
No known key found for this signature in database
GPG Key ID: 3DFA8122BB7FD6B8
6 changed files with 55 additions and 41 deletions

View File

@ -58,40 +58,38 @@ describe('getSystemPrompt — Python Code nodes', () => {
// n8n's view can be wrong in the permissive direction here, so the safe assumption
// has to lead — reporting the configured list as fact would invite a runtime break.
// This is the shape cloud runs in.
it('assumes nothing is importable when the runner is configured separately', () => {
const prompt = getSystemPrompt({
pythonImportPolicy: { stdlib: ['re'], external: [], authoritative: false },
});
expect(prompt).toMatch(/external runner mode/i);
expect(prompt).toMatch(/assume no imports are available/i);
expect(prompt).not.toMatch(/allows \*\*only\*\*/);
});
// n8n's values are not the ones an external runner reads, so an invalid one says
// nothing about whether that runner starts. Claiming otherwise would push the
// builder off Python on an instance where it works fine.
it('never claims the runner will not start from a policy it cannot confirm', () => {
const prompt = getSystemPrompt({
pythonImportPolicy: {
stdlib: [],
external: [],
authoritative: false,
misconfigured: true,
},
});
expect(prompt).not.toMatch(/refuse to start/i);
expect(prompt).toMatch(/assume no imports are available/i);
});
it('warns that an invalid allowlist stops the runner starting at all', () => {
it('gives the same safe guidance for an allowlist the runner would reject', () => {
const prompt = getSystemPrompt({
pythonImportPolicy: { stdlib: [], external: [], authoritative: true, misconfigured: true },
});
expect(prompt).toMatch(/refuse to start/i);
expect(prompt).toMatch(/must be used alone/i);
expect(prompt).toMatch(/assume no imports are available/i);
});
// The agent must never point users at deployment configuration. On cloud they
// cannot change it, so naming a setting only generates a support request for
// something nobody will change for them.
it.each([
['nothing allowlisted', { stdlib: [], external: [], authoritative: true }],
['modules allowlisted', { stdlib: ['re'], external: ['pandas'], authoritative: true }],
['wildcard', { stdlib: ['*'], external: ['*'], authoritative: true }],
['not authoritative', { stdlib: ['re'], external: [], authoritative: false }],
['misconfigured', { stdlib: [], external: [], authoritative: true, misconfigured: true }],
])('never names an environment variable or asks for a config change (%s)', (_name, policy) => {
const prompt = getSystemPrompt({ pythonImportPolicy: policy });
expect(prompt).not.toMatch(/N8N_RUNNERS_/);
expect(prompt).not.toMatch(/tell the user their .* setting/i);
});
it('always states the globals the runner defines, whatever the import policy', () => {

View File

@ -141,20 +141,17 @@ function getPythonCodeSection(policy?: PythonImportPolicy): string {
let importRule: string;
// `authoritative` is checked first on purpose: a policy n8n cannot confirm says
// nothing about the runner, including whether its configuration is even valid.
if (!policy.authoritative) {
// Anything n8n reports here may be wrong in the permissive direction, so lead
// with the safe assumption.
const configured =
allowed.length > 0
? ` n8n itself is configured for ${allowed.join(', and ')}, but that may not be what the runner enforces.`
: '';
importRule = `This instance runs task runners in **external runner mode**, so n8n cannot confirm what the runner allows. **Assume no imports are available** and write import-free Python.${configured} If an import fails at run time, rewrite the code without it rather than telling the user it should have worked.`;
} else if (policy.misconfigured) {
//
// None of these name the environment variables behind the allowlist, and none tell
// the user to change them. Whether they *can* is a deployment question the agent
// cannot see — on cloud they cannot, so naming a setting only sends them to support
// over something nobody will change for them.
if (!policy.authoritative || policy.misconfigured) {
importRule =
'This instance combines a wildcard with named modules in an allowlist, which the runner rejects — it will **refuse to start**, so a Python Code node cannot run here at all. Use JavaScript, and tell the user their `N8N_RUNNERS_STDLIB_ALLOW` / `N8N_RUNNERS_EXTERNAL_ALLOW` setting is invalid: a `*` must be used alone.';
'**Assume no imports are available** and write import-free Python, using builtins and str/list/dict methods, or use JavaScript when the task genuinely needs a library. If an import does fail at run time, rewrite the code without it rather than telling the user it should have worked.';
} else if (allowed.length === 0) {
importRule =
'This instance **allows no imports at all** — the allowlists are empty, so `import re`, `import json`, `import datetime` and every package fail at run time with "Import of ... is disallowed". Write import-free Python using builtins and str/list/dict methods, or use JavaScript when the task genuinely needs a library.';
'This instance **allows no imports at all**, so `import re`, `import json`, `import datetime` and every package fail at run time with "Import of ... is disallowed". Write import-free Python using builtins and str/list/dict methods, or use JavaScript when the task genuinely needs a library.';
} else {
importRule = `This instance allows **only** ${allowed.join(', and ')} to be imported. Every other import fails at run time, a module named in the wrong category is rejected too, and relative imports always fail.`;
}

View File

@ -105,7 +105,9 @@ describe('detectPythonCodeConstraints', () => {
const warnings = detectPythonCodeConstraints(workflow('import re\nreturn []'), policy);
expect(warnings.map((w) => w.code)).toEqual(['CODE_NODE_PYTHON_IMPORT']);
expect(warnings[0].message).toMatch(/cannot see the configuration/i);
expect(warnings[0].message).toMatch(/cannot see the allowlist in force/i);
// The agent may repeat this to a user who cannot change the setting.
expect(warnings[0].message).not.toMatch(/N8N_RUNNERS_/);
});
it('flags every import when the configured allowlist is one the runner rejects', () => {

View File

@ -220,6 +220,7 @@ describe('lintPythonCode — native runner constraints', () => {
expect(issues.map((i) => i.code)).toEqual(['CODE_NODE_PYTHON_IMPORT']);
expect(issues[0].message).toMatch(/refuse to start/i);
expect(issues[0].message).not.toMatch(/N8N_RUNNERS_/);
});
it('reports the two allowlist categories separately', () => {
@ -251,10 +252,22 @@ describe('lintPythonCode — native runner constraints', () => {
expect(issues[0].message).toContain('allowlists no imports');
});
it('names both allowlists rather than assuming the module is stdlib', () => {
it('does not assume an unknown policy allowlists nothing on purpose', () => {
const issues = lintPythonCode('import pandas\nreturn []');
expect(issues[0].message).toContain('N8N_RUNNERS_STDLIB_ALLOW');
expect(issues[0].message).toContain('N8N_RUNNERS_EXTERNAL_ALLOW');
expect(issues[0].message).toMatch(/cannot see the allowlist in force/i);
});
// The agent reads these and may repeat them to the user. On cloud the allowlist is
// not theirs to change, so naming a setting only sends them to support.
it.each([
['unknown policy', undefined],
['nothing allowlisted', { stdlib: [], external: [], authoritative: true }],
['modules allowlisted', { stdlib: ['re'], external: ['pandas'], authoritative: true }],
['misconfigured', { stdlib: [], external: [], authoritative: true, misconfigured: true }],
])('never names an environment variable (%s)', (_name, importPolicy) => {
for (const issue of lintPythonCode('import math\nreturn []', { importPolicy })) {
expect(issue.message).not.toMatch(/N8N_RUNNERS_/);
}
});
it('does not flag an unsupported global named only in a comment', () => {

View File

@ -53,17 +53,21 @@ function policyIsUndecidable(policy: PythonImportPolicy): boolean {
/** Renders the allowlist clause shared by the import message. */
function describePolicy(policy: PythonImportPolicy | undefined): string {
// These reach the agent, which may repeat them to the user. Deliberately no
// environment-variable names and no "ask an operator to change it": whether the
// allowlist can be changed at all is a deployment question neither the agent nor
// the user can act on — on cloud it cannot.
if (!policy) {
return 'imports are allowlisted per deployment (N8N_RUNNERS_STDLIB_ALLOW for standard-library modules, N8N_RUNNERS_EXTERNAL_ALLOW for packages, both empty by default) and this check cannot see the configuration in force, so assume none are available';
return 'imports are allowlisted per deployment, this check cannot see the allowlist in force, and the default allows nothing — so assume none are available';
}
if (policy.misconfigured) {
return 'this deployment combines a wildcard with named modules, which the runner rejects — it will refuse to start, so no Python runs at all';
return "this deployment's allowlist is one the runner rejects, so it will refuse to start and no Python runs at all";
}
const parts: string[] = [];
if (policy.stdlib.length > 0) parts.push(`standard-library modules ${policy.stdlib.join(', ')}`);
if (policy.external.length > 0) parts.push(`packages ${policy.external.join(', ')}`);
if (parts.length === 0) {
return 'this deployment allowlists no imports at all (N8N_RUNNERS_STDLIB_ALLOW and N8N_RUNNERS_EXTERNAL_ALLOW are both empty)';
return 'this deployment allowlists no imports at all';
}
return `this deployment allowlists only ${parts.join(', and ')}`;
}

View File

@ -14,7 +14,7 @@ const commonDescription: INodeProperties = {
noDataExpression: true,
builderHint: {
propertyHint:
'Runs in a locked-down native Python sandbox. IMPORTS ARE OPT-IN PER DEPLOYMENT: nothing is importable unless an operator allowlisted it (N8N_RUNNERS_STDLIB_ALLOW for standard-library modules, N8N_RUNNERS_EXTERNAL_ALLOW for packages), and both are empty by default, so on a stock instance `import re`, `import json`, `import math`, `import datetime`, `import pandas` and relative imports all FAIL at runtime with "Import of ... is disallowed". Assume nothing is importable unless you have been told this instance\'s allowlist — write import-free Python using builtins and str/list/dict methods, or set language to javaScript when the task genuinely needs a library. NO network access whatever the allowlist says: requests, urllib, httpx and other HTTP libraries are unavailable — use the HTTP Request node and process its output in this node instead. The ONLY globals are _items (runOnceForAllItems mode ONLY), _item (runOnceForEachItem mode ONLY) and print(); reading the accessor belonging to the other mode raises NameError. There are no cross-node helpers either — _("Node Name"), _input, _json, _today and _jmespath are all undefined, so read data from the connected upstream node only.',
'Runs in a locked-down native Python sandbox. IMPORTS ARE OPT-IN PER DEPLOYMENT and off by default, so on a stock instance `import re`, `import json`, `import math`, `import datetime`, `import pandas` and relative imports all FAIL at runtime with "Import of ... is disallowed". Assume nothing is importable unless you have been told this instance\'s allowlist — write import-free Python using builtins and str/list/dict methods, or set language to javaScript when the task genuinely needs a library. Never tell the user to change the allowlist: on managed deployments they cannot. NO network access whatever the allowlist says: requests, urllib, httpx and other HTTP libraries are unavailable — use the HTTP Request node and process its output in this node instead. The ONLY globals are _items (runOnceForAllItems mode ONLY), _item (runOnceForEachItem mode ONLY) and print(); reading the accessor belonging to the other mode raises NameError. There are no cross-node helpers either — _("Node Name"), _input, _json, _today and _jmespath are all undefined, so read data from the connected upstream node only.',
},
};
@ -41,7 +41,7 @@ export const pythonCodeDescription: INodeProperties[] = [
},
},
{
displayName: `${PRINT_INSTRUCTION}<br><br>The Python option does not support <code>_</code> syntax and helpers, except for <code>_items</code> in all-items mode and <code>_item</code> in per-item mode.<br><br>Imports are disabled unless your instance sets <code>N8N_RUNNERS_STDLIB_ALLOW</code>.`,
displayName: `${PRINT_INSTRUCTION}<br><br>The Python option does not support <code>_</code> syntax and helpers, except for <code>_items</code> in all-items mode and <code>_item</code> in per-item mode.<br><br>Imports are disabled unless your instance allows them.`,
name: 'notice',
type: 'notice',
displayOptions: {