Tools security model
What the framework enforces for you, and what remains your responsibility as a tool author. Machinery: src/core/tools/{dispatch,security,loader,paths,register}.ts.
What the framework enforces
Every client call to a tool action goes through dispatchToolRequest (src/core/tools/dispatch.ts), which applies — in order, all fail-closed:
- Options shape.
optionsmust be an object (or absent) before anything else runs. - Tool-name shape. The name must match
^tool_[a-z0-9_]+$— checked before any DB lookup or filesystem access. - Registry + per-user authorization. The tool must be ACTIVE in the registered-tools section (dd1324) and authorized for the calling user (
getUserTools: admins get every active tool; others the profile-granted set plusalways_activetools). A directory dropped on disk is not callable until it is registered and, separately, granted to the caller's profile. - Loaded server module. The tool must have a
server/index.tsthat loaded successfully at scan time (loader.ts). Discovery is a deterministic, allowlisted directory scan over the configured tool roots — never a request-supplied path: the import specifier is built only from an already-validated root + a name that already matched the tool-name pattern, and the canonical resolved path is confined under that root before the dynamicimport()runs (TOCTOU-safe). There is no request-time filesystem resolution step to bypass because there is no request-time filesystem access at all. apiActionsallowlist. The requested method must be a key of the module'sapiActionsobject. A tool whoseserver/index.tsfailed to load, or that never declares the method, is refused — an action exists on the API only if it is literally a property ofapiActions.- Signature contract — structural, not runtime. A handler is a typed TS function
(context: ToolActionContext) => Promise<ToolResponse>; the loader'svalidateModulecheck rejects a module whoseapiActions[method].handleris not a function. There is no way to accidentally expose a scalar/variadic-signature method, because the contract is enforced by the type system and one loader-time check. - Declarative permission gate.
assertActionPermissionruns the action's declaredpermission/minLevelbefore your handler and before any background fork. Missing or ill-typed required option fields (e.g. nosection_tipoon atipogate) fail closed withinvalid_request/unauthorized— the client receives the standard tool-response shape, never a partial success.
At registration time (register.ts) the framework additionally validates: the register.json format/schema (authoring files only — the 34 seeded files are column-keyed pass-through), the tool/directory name match, and (via the loader) that the server module — if any — satisfies the ToolServerModule contract.
For out-of-repo roots (config.tools.additionalRoots / DEDALO_ADDITIONAL_TOOLS): each root is canonicalized and refused if missing, not a directory, or a system temp directory (rootIsForbidden in paths.ts); the in-repo root always wins name collisions, which are reported (getToolLoadCollisions()), never silently overridden. Additional-root asset URLs are the tool author's/installer's responsibility to serve same-origin — the client import()s tool JS from wherever getToolUrl() resolves.
What YOU must do
- Declare
apiActionswith the least permission that fits each action:UseapiActions: { read_something: { permission: 'tipo', minLevel: 1, handler: readSomething }, write_something: { permission: 'record', minLevel: 2, handler: writeSomething }, }'record'whenever the action targets one caller-suppliedsection_id: it adds the project-scope check on top of the section/tipo permission, so users cannot reach records outside their projects.
Bind the gate to what the action writes. If the handler's targets come from options.sqo, from a nested client map (a tool_config.ddo_map) or from a section it pins by constant, declare permission: 'targets' with a targets(options) extractor that derives every (section_tipo, tipo?, section_id?) the handler will mutate — off the same keys the handler reads:
update_cache: {
permission: 'targets', minLevel: 2,
targets: (options) => sqoSections(options.sqo).flatMap((section_tipo) =>
selection(options).map((item) => ({ section_tipo, tipo: item.tipo }))),
handler: updateCache,
}
section/tipo gate on a sibling field (options.section_tipo while the SQO names another section) authorizes something the action never touches. test/unit/action_scope_binding_tripwire.test.ts refuses that shape.
The extractor cannot see a record the handler derives. When the destination record is bound at run time — tool_import_files takes it from a filename's numeric prefix (enumerate), from a matcher hit (match / match_freename), or routes a role write to the caller/target record — prove it in the handler at the point it is bound and before the first write, with the save door's own rule (assertRecordWriteTarget from src/core/security/record_scope.ts); a record the run itself created is admitted as a create is. A gate that stops at the extractor refuses the record when the client spells its id and writes it when the client spells a filename.
-
Keep imperative gates inside long-running/background handlers. The background executor (
scheduleBackground) does not re-run the per-action gate a second time when the handler actually executes — the declarative gate already ran once, before scheduling — but if a handler is SQO-wide (no single record to gate on, e.g.tool_propagate_component_data) it should still assert its own scope defensively. Seetools/tool_dev_template/server/index.tsfor the map-form pattern andtools/tool_propagate_component_data/server/*.tsfor an SQO-wide handler. -
Never list lifecycle hooks (
isAvailable,onRegister,onRemove) insideapiActions— the loader throws and refuses to load the whole module if you do. -
Confine every caller-supplied path. Any action that receives filenames (uploads, staged imports, etc.) must resolve and canonicalize the path, then prefix-check it against the expected base directory before touching the filesystem — the same pattern
paths.ts/loader.tsuse internally. Seetools/tool_import_dedalo_csv/server/index.ts(importDir/safeImportFile, confining the per-user staging directory) for the shipped pattern. -
Keep secrets out of client config. Only config properties flagged
"client": truereach the browser viagetToolClientConfig/getToolClientConfigRaw— and anything flagged so WILL reach it. API keys and credentials belong in unflagged (server-only) properties (seetool_lang'stranslator_config, which keepsuri/keyunflagged whiletranslator_engineisclient:true). -
Declare
backgroundRunnableexplicitly for the (few) actions allowed to run detached. Everything else should not be listed, or abackground_running:truerequest for it is refused withbackground_not_allowed. -
Validate your inputs. The framework guarantees
optionsis an object from an authorized user who cleared the declared permission — not that its fields are sane. Check types and ranges before acting.
Note on the development template
tools/tool_dev_template/server/index.ts carries no fail-closed guard against production registration — it is a normal module, gated the same way as any production tool (registry + per-user authorization). Treat it as a copy-and-rename starting point, not as something to register on a production install.