- Before the action, inline, where it can block it with a reason. Example: refuse any file whose content looks like a private key, whatever path it arrived by. The API and the rest of this page call this kind a middleware.
- After the action, in the worker, where it cannot change the outcome. Example: log every model response over a token threshold to your own audit trail. The API and the rest of this page call this kind a hook.
Scripts exist on on-prem deployments only (
ON_PREM=true, which every
on-prem install already sets). They never run on the Noxus cloud. Only platform
administrators can see or manage them.Where to find it
On an on-prem deployment, Settings → Platform → Extensions → Scripts opens one list of scripts, next to Plugins. The Runs column says whether a script runs Before an action (a middleware) or After it (a hook), and the filter in the header shows All, Before the action or After the action. New script opens the editor, where When it runs picks the timing and the event together, for example Before: File upload. See runs opens the shared run history. A restored database on a non-on-prem deployment runs none of its scripts.Events
Every payload also carries
name, group_id, user_id, run_id and occurred_at.
The editor’s Event page names the dataclass main(event) receives and shows an
example payload; its Fields button lists every field with its type and meaning.
Chat calls made through the execution context’s standard model builder are gated,
including cached replies, system instructions, workflow nodes, agents, Genie, current
V2 browser playbooks, verification models resolved in that context, and KB ingestion
using ctx.kirby. The registry hook also routes bare preset/model slugs in an active
execution context through that builder. Separate raw model clients without that context
are not covered. Decision models (including decision-based guardrail scans), embeddings,
rerankers, speech-to-text, text-to-speech, and legacy browser/audio model clients are
outside this chat-event contract. Model health probes are explicitly excluded because
a script denial must not suspend a healthy provider. purpose may be null.
The payload keeps the most recent 64 KB of transcript. truncated indicates discarded
content; a policy needing the whole transcript should deny truncated requests. Retry
checks include the current identity and script configuration, while repeated attempts
with the same identity, policy and transcript share a verdict.
File events cover writes through platform storage. Inline data URIs and channel
attachments that remain external URLs are not storage uploads and do not receive
file events. ZIP members stored by extraction are classified as imports (or chat files
for agent attachments) and are checked separately. A denied email attachment is removed
before bundling. Batch uploads commit per file; earlier accepted files can remain if a
later file is refused. Some channels drop denied attachments without a refusal reply.
api events are the audited, successful mutating calls: what the audit log records,
including calls made through the MCP server. Routes that opt out of auditing and calls
that failed produce no event.
Uploads and created files
file.create fires for every file the platform stores: uploads, workflow node and
agent outputs, plugin files, files a connector syncs into a knowledge base, and
exports. file.upload is the subset a person brought in: uploads in the app, the API
and the embedded widget, knowledge base uploads, workflow imports, and attachments
that arrive through Slack, Teams, Telegram, Google Chat or email. An upload fires
both, file.upload first, so a policy on what users bring in binds to file.upload
and a policy on every file binds to file.create. The hook events file.uploaded and
file.created follow the same rule. Files the platform keeps for itself, such as
spilled node outputs and run previews, fire neither. The files a workflow unpacks
from an uploaded zip are created files: file.upload sees the archive, file.create
sees each member.
Every file event says where the file came from in event.source, so one script can
treat sources differently:
The first eight are uploads and also fire
file.upload. event.format is the file’s
extension in lowercase (pdf, csv, docx), and event.content_type its MIME type.
Reading the file
A file event does not hand the script an open file. It copies the file into the script’s working directory and puts the name of that copy inevent.file_path,
so the script opens it like any local file and the document parsers take the path:
file.upload middleware always gets the whole upload,
whatever its size: a secret scanner has to see every byte, and text_excerpt is only a
convenience for the first 64 KB of text. Read big files in chunks rather than into
memory; the copy counts against the script’s budget. For file.uploaded the worker
downloads the stored file before the hook runs, up to EXTENSIONS_MAX_FILE_BYTES
(64 MB by default); above that the hook still runs with file_path set to None. The
copy is deleted with the working directory when the script finishes.
Writing a script
A script defines one function,main(event), and imports its types from
noxus_script:
eventis a frozen dataclass for the event the script binds to, so its fields are typed attributes:FileUpload,FileUploaded,LLMRequest(whosemessagesareLLMMessagedataclasses),LLMResponseandApiCall. The Event page lists each field with its type, and the editor completes them afterevent.. The module is built from the platform’s event definitions, so it always matches what is sent.- For a middleware, return
Deny("reason")to block. ReturningNoneorAllow()lets the action through. A plain{"allow": False, "reason": "..."}dict works too. - The reason is the message the user sees, wherever they uploaded the file or made the request: the run form, a knowledge base, a chat, a table, the API. A refused model call shows it as the workflow run’s error, in agent and Genie chat, in a deployed widget even when its error banners are hidden, and as the reply on Slack, Teams, WhatsApp, Telegram and Google Chat. Leave it out (or return an empty one) and the action just fails with a plain message, This file could not be uploaded. or This request could not be completed., that does not say a middleware blocked it. The script’s name never reaches the user; the run history keeps it, with the reason or the failure, for admins.
- For a hook, the return value is ignored.
- Scripts run in a Python environment of their own with the standard library plus
requests,pypdf,python-docx,openpyxl,python-pptx,beautifulsoup4,lxml,pyyaml,python-dateutilandchardet. The editor lists what the running deployment actually provides. Platform packages are never importable. - Outbound HTTP is disabled by default; set
EXTENSIONS_ALLOW_NETWORK=trueonly with an external egress policy to allow the network modules when a script is saved and, on kernels that support it, TCP at the kernel. Subprocesses,ctypes,multiprocessingand similar modules are always refused. - The interpreter runs with an empty environment, hard CPU, memory and process limits,
and a Landlock ruleset (Linux 5.13 or later) that leaves the filesystem readable only
under the interpreter’s own trees and the files name resolution and TLS need, and
writable only in a throwaway working directory. On a kernel without Landlock the run
fails instead of running unconfined; set
EXTENSIONS_REQUIRE_SANDBOX=falseonly if you accept that. - The environment is built into the platform image at
/opt/extensions-venv; a developer checkout builds it withmake extensions-venv, andEXTENSIONS_PYTHONpoints at a different interpreter if you maintain your own. Without one, scripts get the standard library only. print()goes to the run’s captured output, which you can read in the run history.- The editor completes as you type: the modules this deployment’s scripts can import,
a module’s functions and classes with their signature after
module.orfrom module import, and the event’s fields afterevent.. The list is read from the scripts’ own interpreter, so blocked modules are never suggested. - A new script starts from a sample for its event, which follows the event until you edit it. Load sample code on the Script page swaps in the sample for the current event, after asking first if it would replace code you wrote.
Testing a script
The editor’s Test page runs the saved script against the event’s sample payload, or one you edit. For the four file events, the payload section takes a real file: its name, type, size, hash and text excerpt replace the payload’s file fields, and it is copied in asevent.file_path the way a live upload is. The file is
never stored, so a file middleware does not gate its own test. Test runs are recorded,
marked as tests, in See runs and under the Tests filter of the script’s Runs page,
and never touch live traffic.
Mode, failure policy and scope
The Mode select, in each row of the list and on the editor’s Overview page, sets what a script does: one that runs before an action is Disabled, in Dry run or Enforcing; one that runs after is Disabled or Enabled. A new script that runs before starts in dry run, where it records what it would have blocked without blocking anything. Watch the run history, then switch to Enforcing once it only blocks what you expect. Moving a script out of Enforcing from the list asks you to confirm, because the actions it was blocking go through from then on. If a middleware crashes or runs out of time, the failure policy decides: fail closed (the default) blocks the action with the same plain message as a denial without a reason; fail open lets it through. Either way the run history records the error. A script applies to every workspace unless you scope it to one. Anapi hook can be
narrowed to a resource type and an action, chosen from dropdowns that default to
Any (*), and must opt in to receive request bodies.
Run history
See runs on Scripts lists every run after an action and every run before an action that denied, failed, timed out or would have denied in dry run, with the same Before and After filter as the list; the editor’s Runs page shows the same for one script, with outcome (Failures, Would block) and Live or Tests filters. Both lists have a search that matches the script name, the event, the reason, the error, the resource and the captured output. Clicking a run opens its details in place: outcome, reason or error, the resource it looked at, and the captured output. Allowed middleware runs are counted in metrics but not stored. Rows are pruned afterEXTENSIONS_RUN_RETENTION_DAYS (30 by default).
A run row keeps the last 4 KB of what the script printed and the error text, and every
platform administrator can read it. Print identifiers, not the content the script looked
at.
Managing scripts over the API
Every/extensions endpoint also accepts the deployment’s admin key in the
X-API-Key header, so scripts can be provisioned by tooling or a configuration
management run without a browser session. Scripts created this way have no creator.
The key is ADMIN_API_KEY in the backend environment.
Any other key, and any session that is not a platform administrator, gets 401 or 403;
on a deployment that is not on-prem the paths do not exist.
Limits and guardrails
A script runs admin-written code inside a backend or worker process’s host, so every run is boxed in, and a broken or heavy script degrades into its own failure policy rather than into an outage. The editor’s Dependencies and limitations page, opened from the button under the script, lists the limits in force and the available libraries.- Each run is a fresh interpreter with an empty environment: 2 s wall clock
(including copying a staged file in), 2 s of CPU,
EXTENSIONS_MEMORY_MB(256) of address space, 1 MB per written file, 64 open files, and at mostEXTENSIONS_WORKDIR_MB(64) andEXTENSIONS_WORKDIR_FILES(256) written in all; past those the script is killed. - The kernel decides who dies. The interpreter runs at lower CPU priority and with the highest OOM score, so under memory pressure the kernel prefers the script. Container-level OOM policies can still terminate the whole pod. When the platform runs as root, the script runs as an unprivileged user (uid 65534), so it shares no user with the platform and holds no capabilities. A seccomp filter lets it signal, re-limit or reschedule only itself, never fork or trace, and open only TCP sockets (none at all with the network off). Landlock keeps the filesystem read-only outside a throwaway working directory.
- Network. Network access is disabled by default. To enable outbound HTTP, set
EXTENSIONS_ALLOW_NETWORK=true, enforce a deployment egress policy that prevents scripts from reaching private, loopback and metadata destinations directly, and setEXTENSIONS_ASSUME_EGRESS_POLICY=trueto attest that policy. This is required on every Landlock ABI. Landlock limits destination ports, not destination addresses; the Python socket wrapper is convenience, not an address-level security boundary. The loopback proxy resolves and pins destinations, checks the platform SSRF policy, and allows onlyEXTENSIONS_ALLOWED_PORTS. Its checks protect requests that pass through it.EXTENSIONS_REQUIRE_SANDBOX=truerefuses a networked run without the external policy attestation. The disabled-network path uses kernel socket restrictions. - Bursts. A process runs
EXTENSIONS_MAX_CONCURRENT(8) scripts at once. A middleware run waits up toEXTENSIONS_SLOT_WAIT_MS(2000) for a slot, then gets its full budget, with at mostEXTENSIONS_MAX_QUEUED(128) waiting. Worker hooks run sequentially within each job and wait for execution capacity without the inline admission deadline. A run the platform cannot serve (no slot, a full queue, too little disk) is refused at once, and a refused middleware blocks the action whatever its failure policy says: fail open means the script’s own failures let the action through, never that flooding the platform skips the check. Size a pod forEXTENSIONS_MAX_CONCURRENTxEXTENSIONS_MEMORY_MBon top of its usual use. - Broken scripts. A script that fails 5 times in a row on its own (error, timeout or rejection) is skipped for 60 s, its failure policy applying meanwhile (fail open lets the action through, fail closed blocks it), then gets one try; platform refusals do not count, and the Test page still runs it. Saving a new version starts it afresh.
- Fan-out. At most
EXTENSIONS_MAX_SCRIPTS_PER_EVENT(5) enabled scripts per event and kind; several middleware on one event run concurrently and any denial blocks. - Disk. An upload is written to disk once and copied, read-only, into each
script’s working directory. Staging keeps
EXTENSIONS_MIN_FREE_DISK_MB(1024) free on the temp volume, or the action is refused; working directories a crashed process left behind are removed on start. - Records. A script writes at most 300 live run rows a minute per process; the rest
are counted in metrics only. Enforced blocks get ten times that room (3000 a minute). Rows are kept
EXTENSIONS_RUN_RETENTION_DAYS(30) days. - Hooks run on the worker’s own
extensionspool,WORKER_EXTENSIONS_CONCURRENCY(4) at a time. An event older thanEXTENSIONS_HOOK_MAX_AGE_SECONDS(900) when its turn comes is dropped, so a backlog after an outage does not replay stale side effects. An upload’sfile.uploadedandfile.createdhooks run in one job on one download of the file. Audit bodies over 64 KB are not passed on. A hook may see an event twice after a worker restart, and one can be lost if the process exits between committing the action and queueing the hook: make repeats harmless, and do not use hooks as the only record of anything. A queued hook job holds its event, so anllm.responseanswer excerpt or an opted-inapibody sits in the job queue table until the job is cleaned up. - Sizes. 64 KB of source, 64 KB of excerpt and transcript, 64 KB of result; 64 MB
of downloaded file for a
file.uploadedhook (an upload middleware gets the whole file).
noxus_extensions_guardrail_total by event and reason
(queue_full, slot_wait, circuit_open, low_disk, hook_stale,
record_suppressed, body_dropped, and egress_blocked with the event network).
On Docker Compose, the backend and worker containers need a seccomp profile that
allows the landlock_* syscalls and seccomp itself. Recent Docker defaults do; if
every run fails with “Landlock is not available”, the engine’s profile is older.
Before and after in Settings
Middleware and hooks share one Scripts list, editor, test panel and run history. Settings calls a middleware a script that runs Before the action and a hook one that runs After it. Existing middleware and hook links still select the matching filter. Choose when the script runs and its event under When it runs when creating it. An existing script keeps its timing; create another script to move a policy from before to after. A script that runs before an action runs inline and can block it. One that runs after it runs asynchronously and cannot change its outcome. Only scripts that run before offer dry run and failure policy controls.Operational metrics
Importcdk/grafana/dashboards/extensions.json for evaluation outcomes, blocks,
admission pressure, phase latency, hook dispatch failures and event-loop responsiveness.
All executions use fresh interpreters. There is no warm pool.
When metrics sending is enabled, NCS also receives aggregate extension counters from
the latest backend, relay and worker process samples within five minutes. These are
snapshots, including test evaluations, not counts in the check-in reporting window.
Retries replace the snapshot. Values can fall after restarts or process expiry, and
work since a process’s final sample can be lost. No script identifiers or payloads
are sent. The existing metrics-sending opt-out applies.