Skip to main content
Scripts let a platform administrator attach a short Python script to a platform event without writing a plugin. The script runs inside a throwaway, sandboxed interpreter with a two second budget and sees only the event it was given, plus a copy of the file for file events. A script runs either before an action or after it:
  • 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 in event.file_path, so the script opens it like any local file and the document parsers take the path:
The copy keeps the original base name (path separators stripped) so parsers that key off the extension work. A 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:
  • event is a frozen dataclass for the event the script binds to, so its fields are typed attributes: FileUpload, FileUploaded, LLMRequest (whose messages are LLMMessage dataclasses), LLMResponse and ApiCall. The Event page lists each field with its type, and the editor completes them after event.. 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. Returning None or Allow() 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-dateutil and chardet. The editor lists what the running deployment actually provides. Platform packages are never importable.
  • Outbound HTTP is disabled by default; set EXTENSIONS_ALLOW_NETWORK=true only 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, multiprocessing and 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=false only if you accept that.
  • The environment is built into the platform image at /opt/extensions-venv; a developer checkout builds it with make extensions-venv, and EXTENSIONS_PYTHON points 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. or from module import, and the event’s fields after event.. 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 as event.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. An api 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 after EXTENSIONS_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 most EXTENSIONS_WORKDIR_MB (64) and EXTENSIONS_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 set EXTENSIONS_ASSUME_EGRESS_POLICY=true to 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 only EXTENSIONS_ALLOWED_PORTS. Its checks protect requests that pass through it. EXTENSIONS_REQUIRE_SANDBOX=true refuses 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 to EXTENSIONS_SLOT_WAIT_MS (2000) for a slot, then gets its full budget, with at most EXTENSIONS_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 for EXTENSIONS_MAX_CONCURRENT x EXTENSIONS_MEMORY_MB on 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 extensions pool, WORKER_EXTENSIONS_CONCURRENCY (4) at a time. An event older than EXTENSIONS_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’s file.uploaded and file.created hooks 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 an llm.response answer excerpt or an opted-in api body 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.uploaded hook (an upload middleware gets the whole file).
Every stop is counted in 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

Import cdk/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.