Security Guide

Overview

Scriptling’s security boundary depends entirely on how it is configured. You are responsible for registering only the capabilities your scripts need and for applying process-level isolation where appropriate.

Bare Embedding vs CLI Defaults

A bare scriptling.New() interpreter starts without standard or extended libraries; the embedding host chooses what to register. It still provides the language runtime and import mechanism, but an import succeeds only after the host registers a library or configures a loader.

The scriptling CLI is intentionally more permissive. Unless disabled, its setup registers the standard libraries plus extended capabilities including requests, os, and subprocess:

  • requests can reach the network unless --network-policy restricts it.
  • os can access the filesystem unless --allowed-paths restricts its filesystem operations.
  • os.getenv() reads the live process environment, and os.environ() exposes a snapshot of it. Filesystem allowlists do not filter environment variables.
  • subprocess can execute host commands and bypass library-level filesystem and network controls.

Scriptling runs in a memory-safe Go environment without C extensions and does not directly expose the Go runtime, but the CLI capabilities above still provide host access. Do not describe the default CLI as a no-filesystem, no-network, or no-environment sandbox.

Library Security

Pure Standard Libraries

These libraries do pure computation with no filesystem, network, or process access, so embedding hosts can register them freely (the CLI includes them; a bare scriptling.New() does not): math, json, datetime, time, string, base64, html, re, random, statistics, textwrap, functools, itertools, collections, hashlib, hmac, and uuid. platform is read-only platform information, and urllib parses URLs without making network requests.

Scriptling-Specific Libraries

These libraries provide Scriptling-specific functionality. This is a capability inventory, not a list of libraries available in every runtime; embedding hosts register them explicitly and CLI availability varies by mode and build.

Library Security Considerations
scriptling.ai NETWORK ACCESS - Makes HTTP requests to AI APIs
scriptling.ai.agent NETWORK + CODE EXECUTION - Agentic AI with tool execution
scriptling.mcp NETWORK ACCESS - MCP protocol communication
scriptling.net.* NETWORK ACCESS - DNS, UDP/TCP, gossip, multicast, and WebSocket clients
scriptling.messaging.* NETWORK + REMOTE INPUT - Bot clients receive events and send messages using script-supplied credentials
scriptling.container INFRASTRUCTURE CONTROL - Manages Docker, Podman, and Apple Containers
scriptling.nomad INFRASTRUCTURE CONTROL - Registers/stops jobs and manages Nomad resources
scriptling.oauth NETWORK ACCESS - Signs in to identity providers and handles access tokens
scriptling.plugin PROCESS OR NETWORK ACCESS - Starts executable peers or connects to plugin servers
scriptling.provision.* FILESYSTEM + NETWORK ACCESS - Changes files and can download remote content
scriptling.secret Host-controlled secret access; scripts see aliases, not provider credentials
scriptling.console Console I/O, including interactive input

scriptling.similarity and scriptling.toon are pure computation with no external access.

Runtime Libraries

These libraries provide server and concurrency functionality:

Library Security Considerations
scriptling.runtime Background task execution (isolated and shared-env threads)
scriptling.runtime.http HTTP SERVER - Registers HTTP routes and handlers
scriptling.runtime.kv In-memory key-value store - safe but unbounded memory
scriptling.runtime.sync Concurrency primitives - safe but can cause deadlocks
scriptling.runtime.sandbox CODE EXECUTION - Executes code in isolated environments

Extended Libraries (Require Explicit Registration)

These libraries extend functionality but require explicit registration:

Library Security Considerations
requests NETWORK ACCESS - Allows HTTP/HTTPS requests to external URLs
os FILE SYSTEM ACCESS - Controlled by allowed paths
os.path FILE SYSTEM ACCESS - Path operations, controlled by allowed paths
pathlib FILE SYSTEM ACCESS - Object-oriented paths, controlled by allowed paths
glob FILE SYSTEM ACCESS - Pattern matching, controlled by allowed paths
subprocess CRITICAL RISK - Allows arbitrary command execution
sys SYSTEM ACCESS - Provides access to system internals
secrets Cryptographically secure random generation
logging File and console logging - may write to disk
scriptling.wait_for Network/resource polling - may access network; file checks controlled by allowed paths
scriptling.provision.fetch HTTP/HTTPS downloads and filesystem writes; insecure=True skips TLS verification
yaml YAML parsing - safe but watch for large files
toml TOML parsing - safe but watch for large files
html.parser HTML parsing - safe but watch for large files

Never Register in Untrusted Environments

Do NOT register these libraries when executing untrusted code:

  • subprocess - Allows arbitrary command execution
  • sys - Provides access to system internals and environment
  • scriptling.runtime.sandbox - Can execute arbitrary code
  • scriptling.ai.agent - Can execute AI-generated code with tools

File System Security

Restricting File Access

When registering filesystem libraries, you must specify allowed paths:

// Safe: Only allows access to specific directories
extlibs.RegisterOSLibrary(p, []string{
    "/tmp/myapp/data",
    "/home/user/documents",
})
extlibs.RegisterPathlibLibrary(p, []string{
    "/tmp/myapp/data",
    "/home/user/documents",
})
extlibs.RegisterTempfileLibrary(p, []string{"/tmp/myapp"})
extlibs.RegisterShutilLibrary(p, []string{
    "/tmp/myapp/data",
    "/home/user/documents",
})

// Dangerous: Allows access to entire file system
extlibs.RegisterOSLibrary(p, nil)        // Nil = no restriction (full read/write access)
extlibs.RegisterOSLibrary(p, []string{}) // Empty = deny all (no paths allowed)

All filesystem libraries (os, pathlib, fs, glob, tempfile, shutil, zipfile, tarfile, scriptling.grep, scriptling.find, scriptling.sed) accept allowedPaths and enforce the same path traversal and symlink protections. A path is resolved the way the kernel resolves it when the file is opened: every symlink component is followed before any .. is applied, so a symlink inside an allowed directory cannot lead outside it — including a dangling symlink, one whose target does not exist yet (a script cannot plant allowed/link -> ../../etc/x and then create the file through it). os.symlink() likewise refuses to create a link whose target, resolved against the link’s own directory, falls outside the allowed set. Archive libraries (zipfile, tarfile) additionally block zip-slip / tar-slip attacks (path traversal via crafted entry names).

Path Traversal Protection

Scriptling’s file system libraries automatically prevent path traversal attacks:

# User tries to escape allowed directory
import os
import pathlib

allowed_path = "/tmp/myapp/data"
# Trying to access parent directories
os.read_file("/tmp/myapp/data/../../etc/passwd")  # BLOCKED
os.read_file("/tmp/myapp/data/secrets.txt")        # ALLOWED

path = pathlib.Path("/tmp/myapp/data/../../../etc/passwd")  # BLOCKED

Network Security

Network-Enabled Libraries

These libraries can make network requests:

  • requests - HTTP client library
  • scriptling.sql - MySQL, MariaDB and PostgreSQL client (see Database Drivers)
  • scriptling.valkey - Valkey/Redis client (see Database Drivers)
  • scriptling.ai - AI API client
  • scriptling.ai.agent - Agentic AI with tool execution
  • scriptling.mcp - MCP protocol client
  • scriptling.wait_for - Resource polling (may check network endpoints)
  • scriptling.provision.fetch - HTTP/HTTPS downloads and optional zip unpacking

Disabling Network Access

To keep the sandbox network-free, don’t register network-enabled libraries:

// Safe: Only register standard libraries (no network access)
stdlib.RegisterAll(p)

// Unsafe: Registers network-enabled library
extlibs.RegisterRequestsLibrary(p)

Network Policies

For scripts that should reach the internet but must never reach your private network, register a network policy. A policy governs the requests, scriptling.wait_for, scriptling.net.websocket, scriptling.net.unicast, scriptling.net.multicast, scriptling.net.gossip, scriptling.nomad, scriptling.oauth, scriptling.ai, scriptling.mcp, and scriptling.provision.fetch libraries, the Slack, Discord and Telegram messaging libraries, and the container endpoints a script passes to scriptling.container, and is enforced at connect time: the hostname is resolved through the configured DNS servers, every resolved address is checked against the policy, and the connection is made to the validated address directly. That closes the usual bypasses — DNS rebinding (the answer changing between check and connect), redirects to internal hosts, and IP-notation tricks.

With a policy active, loopback, link-local (including cloud metadata endpoints like 169.254.169.254), private, reserved (CGNAT 100.64.0.0/10, documentation and benchmarking ranges, NAT64/6to4 and other non-public ranges), unspecified, and multicast addresses are all blocked by default, as are URLs that name an IP directly. Host allow/deny lists, CIDR exceptions, https-only, and custom DNS servers grant exactly the access you intend — allowlisted hosts are trusted to resolve internally (still subject to deny_cidrs, and a link-local answer still needs allow_cidrs), and deny rules always win.

  • CLI: --network-policy=policy.toml — the full policy file reference is in the network policy guide. Combine it with --disable-lib subprocess in any mode so code cannot bypass the policy by shelling out.
  • Embedding: pass a *netsecurity.Config (or load the same TOML file with netsecurity.LoadConfig) when registering the governed libraries — the Config options are documented in the library registration guide. No policy means no restrictions.
policy, err := netsecurity.LoadConfig("policy.toml")
if err != nil {
    return err // an invalid policy must never degrade into an open one
}
extlibs.RegisterRequestsLibrary(p, policy)
extlibs.RegisterWaitForLibrary(p, policy)
extlibs.RegisterWebSocketLibrary(p, policy)
ai.Register(p, policy)
scriptlingmcp.Register(p, policy)

The policy governs the libraries above, including ai.Client()’s remote_servers (the MCP servers it can attach for tool use) and mcp.Client()’s HTTP transport — a script cannot bypass the policy by reaching a blocked endpoint through the AI or MCP client instead of requests. Both scriptling.wait_for probes, http() and port(), honor the policy and raise a network policy: … error for a denied address instead of polling until the timeout. scriptling.net.unicast checks connect() and a UDP listener’s send_to() destinations; listen() is unaffected. scriptling.net.multicast refuses every group in join() unless allow_cidrs grants it. scriptling.net.gossip checks every outbound connection, including to peers learned from the cluster, and sends everything over TCP; with allow_hosts set, peers are limited to the listed names, and a peer given by IP must be granted by allow_cidrs or allow_ip_literals. The Slack, Discord and Telegram libraries also confine the local files send_file() (and Telegram rich-message images) read to --allowed-paths. They send every request and gateway connection through the policy, their platforms’ own hosts included (under a strict allow_hosts, list those hosts), and send the bot token only to the platform’s hosts. nomad.Client(addr) requests, including redirects and insecure=True clients, go through the same checks, as do TCP/HTTPS endpoints a script passes to container.Client(socket=...) (the operator’s configured container endpoints are trusted; a script-supplied Unix socket path must lie within --allowed-paths). Stdio-transport mcp.Client() instances launch a local subprocess, which the network policy does not govern; they are refused wherever the subprocess library is disabled. scriptling.provision.fetch downloads through the same policy as requests, and confines dest, provides and unpacked files to --allowed-paths. Keep any of these unregistered where scripts are untrusted and no policy is configured. HTTP clients an embedding application builds itself and plugin processes are outside the policy (a plugin only enforces what it is handed; see Database Drivers).

Database Drivers

The database plugins enforce the host security policy on every operation, in both compiled-in and external-plugin form (the policy travels in the plugin handshake):

  • scriptling.sqlite and scriptling.badgerdb (file-backed) — the database path must fall inside --allowed-paths / the embedder’s allowed paths. ":memory:" sqlite databases are always allowed.

  • scriptling.sql and scriptling.valkey (network) — connections dial through the same guard as the requests library, so a network policy applies in full. This answers the common question for MySQL/MariaDB/PostgreSQL DSNs: connecting by hostname or by IP are both covered. A hostname is resolved through the policy’s DNS servers and every resolved address is checked; an IP literal is checked directly — and IP literals are denied by default, so mysql://[email protected]/db needs the address allowed explicitly:

    • allow_private_ips = true together with allow_ip_literals = true for arbitrary private IP literals, or
    • the address inside allow_cidrs (an explicit CIDR grant permits that IP literal without allow_ip_literals), or
    • the hostname inside allow_hosts (allowlisted hosts are trusted to resolve internally — the recommended way to grant access to db.internal.corp).

    Loopback (postgres://localhost on the same machine) needs allow_loopback = true. The same rules apply to valkey:// URLs.

When no policy is configured the drivers connect without restriction, exactly like the other network libraries.

The same holds for any plugin that advertises the policy capability, and it is worth being precise about what that means: the policy is delivered to the plugin and enforced by it. It is not a sandbox around a plugin process, so --allowed-paths and --network-policy bound what a cooperative plugin does, never what a malicious one could. Treat third-party plugin binaries like any other executable you choose to run. For plugins that do cooperate the protection is real, and the network guard in particular carries genuine SSRF and DNS-rebinding defences to every dial the plugin makes.

# policy.toml — a script that may reach the database subnet
allow_private_ips = true
allow_hosts = ["db.internal.corp"]

Plugin Loading

Registering scriptling.plugin against an unrestricted manager gives scripts load(name, path) — and path is script-supplied, not host-supplied: a filesystem path is resolved and executed directly (no --allowed-paths check), and an http(s):// URL is fetched over a plain transport with no network policy involved. This is a materially bigger capability than either subprocess or an unrestricted requests alone, since it combines arbitrary local execution with arbitrary outbound fetch in one call. “The host chose which plugins to trust” is only true until the first script calls load.

In the CLI this is handled for you when process execution is disabled: --no-subprocess and --disable-lib subprocess also stop a script loading an executable plugin (a subprocess), while plugins the operator supplied with --plugin / --plugin-dir stay loaded and callable, and HTTP(S) plugins — which are not subprocesses — can still be loaded. Embedders get the same effect with plugin.RegisterLibrariesConfigured(p, manager, policy, allowProcesses) (pass false to refuse script-driven executable loads).

Use plugin.WithTransport(plugin.TransportNone) on the scope you register for scripts to close this off while keeping the host’s own pre-loaded plugins fully usable — list, describe, call_function, batch_call, and call_method all keep working; load and unload always fail. This is the pattern that actually makes admin-supplied plugins trusted-and-safe to expose.

If scripts genuinely need to load new plugins at runtime, restrict that to HTTP(S) endpoints over a policy-enforcing transport instead of opening it up entirely: plugin.WithTransport(plugin.TransportHTTP) combined with plugin.WithHTTPTransport routes every load()/call_function for that scope through a transport you build from the same network policy that already governs requests, scriptling.ai, and scriptling.mcp — a script can’t use plugin loading as a side door around restrictions placed on those libraries.

The same idea applies to stdio/executable loading: plugin.WithExecPaths lets scripts load new plugin executables, but only from directories you allow, instead of anywhere the host process can reach. It combines with WithHTTPTransport on the same scope — one restriction governs which executables may be spawned, the other which HTTP(S) endpoints may be dialed, independently of each other and of WithTransport’s all-or-nothing gate on each transport kind.

A scope created with NewScope and no explicit WithTransport/WithExecPaths option inherits both restrictions from its parent scope rather than defaulting to unrestricted — see Nested Scopes. Only an explicit option on the child overrides what it inherited.

Secret Provider Security

Use scriptling.secret when scripts need secrets but should not receive provider URLs, tokens, or other private configuration.

Recommended pattern: Register providers in the host application and expose only aliases to scripts.

import scriptling.secret as secret

db_password = secret.get("prod_vault", "secret/data/app", "password")

The script only sees prod_vault, the logical path, and the optional field name. The host owns the real provider configuration.

Resource Limits

Execution Timeout

Always set timeouts for script execution:

import "time"

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

result, err := p.EvalWithContext(ctx, code)
if err == context.DeadlineExceeded {
    // Script was terminated due to timeout
}

Memory and Value Limits

Each interpreter can have a memory limit and a value limit (see Script Resource Limits). Both are off until set, so set them for untrusted code, along with a timeout:

  1. Memory limit: cancels the interpreter’s running calls when the estimated memory it holds stays over the limit after a garbage collection. An interpreter that keeps going over, within a call or between calls, forces at most one collection every 100 ms, so a script catching MemoryError in a loop cannot stall the process. The cancellation stops the script at its next statement and cannot be caught with try/except or suppressed by a with block. The same holds for execution timeouts and cancellations, and for a PermissionError from the filesystem jail or other host restrictions.
  2. Value limit: a single string, bytes value, list, tuple, dict, set, read body or captured output larger than the limit raises MemoryError.
  3. Recursion depth is limited to 1000 calls, including calls from Go through CallFunction. Source nesting is limited as in Python (200 nested brackets, 99 nested blocks, expressions 20,000 deep), so a deeply nested program, including one run through the sandbox, is a parse error rather than a stack overflow that would end the process.

Memory is estimated, not measured exactly, and every interpreter shares the host’s process. The limits govern memory, not disk: a single extracted archive member or file read is held to the value limit, but a script can still write many files, so bound disk use with operating-system quotas or a size-limited filesystem where it matters. For untrusted code that must not affect anything else, run the interpreters in a separate process with an operating-system memory limit as well.

Code Injection Prevention

Never Execute Untrusted Input Directly

# DANGEROUS: Never do this with scriptling.runtime.sandbox
import scriptling.runtime.sandbox as sandbox

user_input = get_user_code()  # e.g., "os.remove('/important/file')"
sandbox.eval(user_input)      # Executes arbitrary code!

Note: The scriptling.runtime.sandbox library allows executing arbitrary code strings. Never pass untrusted user input directly to it.

Safe Patterns

# SAFE: Use structured data and predefined functions
user_config = get_user_config()  # Returns validated dict
name = user_config.get("name", "Anonymous")
greet(name)  # Controlled execution

# SAFE: Whitelist allowed operations
allowed_operations = {"add", "subtract", "multiply"}
operation = user_config.get("operation")
if operation in allowed_operations:
    result = perform_operation(operation, x, y)

Environment Variables

The CLI registers os by default: os.getenv() reads the live process environment and os.environ() returns the snapshot captured when the library was registered. --allowed-paths does not filter either API. For untrusted CLI code, sanitize the host process environment and disable os; embedding hosts can omit the library and expose only selected values:

// A bare scriptling.New() has no libraries; pass only approved values.
p.SetVar("APP_VERSION", "1.0.0")
p.SetVar("API_ENDPOINT", apiEndpoint)

Do not rely on omitting sys alone: it is not the CLI’s only environment-access path.

Security Checklist

Use this checklist when deploying Scriptling in production:

  • File system access is restricted to specific paths (os, pathlib, glob)
  • Network access is disabled or restricted per client (requests, databases, networking, messaging, AI, MCP)
  • Execution timeout is configured
  • subprocess library is NOT registered
  • sys and os are omitted or carefully controlled for untrusted code
  • Container, Nomad, plugin, messaging, and provisioning libraries are omitted unless explicitly required
  • scriptling.runtime.sandbox is NOT registered for untrusted code
  • scriptling.ai.agent is NOT registered for untrusted code
  • Environment variables are filtered
  • Untrusted user input is validated
  • Scripts run with minimal privileges
  • Error messages don’t leak sensitive information
  • Logs are sanitized before display
  • A memory limit, a value limit and a timeout are set for untrusted code
  • Runtime KV store size is monitored (unbounded memory)
  • Background tasks are properly managed and terminated

Common Attack Vectors

1. Resource Exhaustion

# Consumes all memory
big_list = []
while True:
    big_list = big_list + ["x" * 1000000]

Mitigation: Set a memory limit and a value limit, and use execution timeouts.

2. Infinite Loops

# Consumes all CPU
while True:
    pass

Mitigation: Use execution timeouts.

3. Path Traversal (Protected)

# Attempt to escape allowed directory
import os
os.read_file("../../etc/passwd")

Mitigation: Scriptling’s os library validates paths against allowed directories.

4. Information Disclosure

# Read a host secret when the CLI's default os library is enabled
import os
secret = os.getenv("SECRET_TOKEN")

Mitigation: Sanitize the process environment and disable or omit os for untrusted code; filesystem path restrictions do not filter environment variables.

Best Practices

  1. Principle of Least Privilege: Only register the libraries that are absolutely necessary
  2. Validate All Input: Never trust user-provided code or data
  3. Use Timeouts: Always set execution time limits
  4. Restrict File Access: Explicitly whitelist allowed directories for os, pathlib, glob
  5. Disable Network: Unless needed, keep the sandbox offline (don’t register requests, scriptling.ai, scriptling.mcp)
  6. Monitor Resource Usage: Watch for unusual memory/CPU consumption
  7. Sanitize Errors: Don’t expose internal paths or stack traces to users
  8. Keep Updated: Update Scriptling regularly for security patches
  9. Limit Runtime State: Monitor scriptling.runtime.kv store size and use TTLs
  10. Control Concurrency: Be aware that scriptling.runtime.sync primitives can cause deadlocks
  11. Sandbox Isolation: Use scriptling.runtime.sandbox carefully - it executes code in isolated environments but still runs in the same process

Reporting Security Issues

If you discover a security vulnerability in Scriptling, please report it responsibly:

  1. Do NOT create a public issue
  2. Submit details via our Security Report Form
  3. Include steps to reproduce
  4. Allow time for a fix to be released before disclosure

Additional Resources