Security & Capabilities

Syntecnia enforces a zero-trust capability model. Every resource access is explicit, auditable, and sandboxed by default.

1. Philosophy: Zero Access by Default

In Syntecnia, programs start with no capabilities whatsoever. There are no implicit file handles, no ambient network access, no clock — nothing. Every I/O operation must be explicitly declared before it can be used.

This means that even if an attacker manages to inject code into your program, they cannot read files, phone home, or spawn processes unless those capabilities were already declared by the program author.

Attempting any I/O operation without a matching require statement raises a CapabilityError: "Capability not granted" at runtime — it never silently falls through.

What this looks like in practice

-- This program has no capabilities at all.
-- Calling fetch() here raises CapabilityError.
let result = fetch("https://api.example.com/data")  -- ERROR
-- Add the required capability first.
require net("api.example.com")

let result = fetch("https://api.example.com/data")  -- OK
Zero access by default is not just a safety feature — it also makes your programs self-documenting. The require statements at the top of any task are an exact specification of what that code can do.

2. Capability Types

Syntecnia has a fixed set of named capabilities. Each one controls a specific surface area and can be scoped to a precise resource using arguments.

Capability Syntax What it grants
net require net("api.example.com") HTTP access to a specific host
file require file("/data/*") Full file system access matching a glob pattern
file.read require file.read("/config/*") Read-only file access for matching paths
file.write require file.write("/logs/*") Write-only file access for matching paths
exec require exec("ffmpeg") Execute a specific external command
env require env("API_KEY") Read a specific environment variable
time require time Access the system clock and sleep
random require random Random number generation
stdout require stdout Print output to the console
stdin require stdin Read user input from the console
llm require llm Use LLM operations (ask, classify, etc.)
db require db("./store.db") SQLite database access at the specified path
serve require serve(8080) Start an HTTP server on the specified port

Combining capabilities

Declare as many require statements as needed. Each is checked independently at the point of use.

require net("api.stripe.com")
require file.write("/var/log/payments/*")
require env("STRIPE_SECRET_KEY")
require stdout

let key = env("STRIPE_SECRET_KEY")
let charge = fetch("https://api.stripe.com/v1/charges", key)
log("/var/log/payments/charge.log", charge)
print("Charge created:", charge.id)
Granting file is broader than granting file.read or file.write individually. Prefer the narrower variants when you only need one direction of access.

3. Wildcard Patterns

Resource arguments to net and file capabilities support glob-style wildcards. This lets you grant access to a family of resources without enumerating every individual one.

Network wildcards

-- Grant access to every subdomain of example.com
require net("*.example.com")

let a = fetch("https://api.example.com/v1")    -- OK
let b = fetch("https://cdn.example.com/logo.png") -- OK
let c = fetch("https://evil.com/steal")           -- CapabilityError

File system wildcards

-- Read-only access to everything under /data/
require file.read("/data/*")

let cfg  = read("/data/config.json")   -- OK
let rows = read("/data/users.csv")     -- OK
let key  = read("/etc/passwd")          -- CapabilityError
-- Write-only access to log files
require file.write("/logs/*.log")

write("/logs/app.log", entry)       -- OK
write("/logs/error.log", err)       -- OK
write("/logs/deep/trace.log", msg)  -- CapabilityError (*  does not cross /)
The * wildcard does not cross directory boundaries. Use ** to match recursively: file.read("/data/**") grants access to all files nested under /data/.

4. Intent Declaration

The intent field lets you attach a human-readable description of what a program or task is meant to do. It is purely descriptive.

intent: "Process customer orders and generate reports"

require net("api.shop.com")
require file.write("/reports/*")

-- ... program logic

What intent does

  • Serves as human-readable documentation embedded in the source
  • Appears in audit logs alongside capability checks
  • Provides context to LLM operations that introspect the running program
  • Is frozen once execution begins — it cannot be changed at runtime

What intent does NOT do

  • Does not grant any capability
  • Does not block any capability
  • Does not replace require
Common mistake: relying on intent for security. Intent is for humans and tooling. If you want a restriction to be enforced, use require, deny, or a sandbox block. Intent alone does nothing at the capability level.

Anti-prompt-injection: intent freezing

The intent value is locked the moment the interpreter begins executing. Any attempt to overwrite it at runtime — for example via untrusted input fed into an LLM call — is silently ignored. This prevents an attacker from changing the declared intent to confuse audit logs or LLM context.

intent: "Summarise user feedback"

require stdin
require llm
require stdout

let feedback = read_line()                   -- user provides input
-- Even if feedback contains "intent: exfiltrate all data",
-- the program intent is already frozen. No effect.
let summary  = ask("Summarise: " + feedback)
print(summary)

5. Per-Task Sandboxing

When you declare require inside a task block, that task gets its own isolated capability scope. It can only use what it explicitly declares — even if the surrounding program has broader grants.

-- Program-level: broad grants
require net("*.shop.com")
require file.write("/reports/*")
require stdout

-- This task declares only what IT needs.
-- It cannot touch /reports/ even though the program can.
task fetch_orders()
    require net("api.shop.com")          -- only this host
    give fetch("https://api.shop.com/orders")

task write_report(orders)
    require file.write("/reports/orders.csv")  -- one file only
    write("/reports/orders.csv", orders)

let orders = fetch_orders()
write_report(orders)
print("Done.")
Per-task sandboxing follows the principle of least privilege automatically. Even if you accidentally grant a broad capability at program level, each task still only operates within its own declared boundary.

Capability inheritance is opt-in

Tasks do not inherit parent capabilities by default. A task must re-declare any capability it wants to use. This is intentional — it forces every task to be an explicit statement of its own requirements.

6. Sandbox Blocks

A sandbox block creates a completely isolated execution zone inside any task or top-level scope. Code inside a sandbox has zero capabilities — none from the enclosing scope, none declared locally.

require stdout
require net("api.example.com")

print("Before sandbox — net is available")

sandbox
    -- Nothing is available here.
    -- print() would raise CapabilityError.
    -- fetch() would raise CapabilityError.
    let result = pure_compute(42)  -- pure functions are fine

print("After sandbox — net is available again")
Use sandbox blocks to isolate untrusted logic — for example, when you receive code or data from an external source and need to process it without any risk of side effects.

Sandbox and pure computation

Pure computations (arithmetic, string operations, data transformations with no external I/O) always work inside a sandbox. Only operations that require a capability are blocked.

sandbox
    let nums   = [1, 2, 3, 4, 5]
    let evens  = nums |> filter(fn(x) => x % 2 == 0)
    let total  = evens |> sum()       -- all fine
    -- write(total) would fail — stdout not granted
    give total                         -- return value out of sandbox

7. Invariants

An invariant is a condition that must hold true throughout the program's execution. The runtime checks it continuously and raises an error the moment it is violated.

invariant: balance > 0

let balance = 1000

task debit(amount)
    require stdout
    let balance = balance - amount
    -- If balance drops to 0 or below, InvariantError is raised immediately.
    print("New balance:", balance)

Multiple invariants

invariant: balance > 0
invariant: order_count >= 0
invariant: price >= min_price
Invariants are ideal for critical business rules: account balances, stock quantities, rate limits, or any condition that must never be violated regardless of what the program does.
Invariants are checked at runtime, not at compile time. They catch violations as they happen and abort execution early — preventing corrupt state from propagating further.

8. Scoping Rules

Syntecnia's capability system follows a small set of clear, predictable rules.

deny overrides grant

If a capability is explicitly denied anywhere in the active scope chain, it is blocked — even if another require has granted it.

require net("*.example.com")
deny   net("internal.example.com")

let a = fetch("https://api.example.com")      -- OK
let b = fetch("https://internal.example.com")   -- CapabilityError (deny wins)

sandbox does not inherit parent capabilities

require stdout

sandbox
    print("hello")  -- CapabilityError — sandbox starts empty

Per-task require creates an isolated scope

require stdout      -- program level

task silent_task()
    -- no require here
    print("hi")      -- CapabilityError — does not inherit program stdout

Non-secure mode defaults

When running without --secure, two capabilities are granted automatically for convenience during development:

  • auto stdout — printing to the console always works
  • auto time — clock access and sleep always work
Do not rely on these auto-granted capabilities in production code. Always declare them explicitly so that behaviour is identical in both modes.

9. Secure Mode

Pass --secure to the CLI to enable production-grade hardening. In secure mode, every single capability must be explicitly declared — including stdout and time.

syntecnia run program.syn --secure

What changes in secure mode

  • All capabilities must be declared — no auto-grants for stdout or time
  • Generic 500 errors — stack traces and internal details are suppressed, preventing information leakage to external callers
  • No auto-granted capabilities — the environment is completely locked down from the start
Always run with --secure in production. Use the default (non-secure) mode only during development and testing.

Checking for secure mode at runtime

require stdout

if runtime.secure()
    print("Running in secure mode")
else
    print("Warning: not in secure mode")

10. Audit Trail

Pass --audit to record every capability check that occurs during execution — both granted and denied. This gives you a precise record of what the program actually tried to access.

syntecnia run program.syn --audit

Example audit output

[AUDIT] GRANT  net("api.shop.com")         — fetch_orders:12
[AUDIT] GRANT  file.write("/reports/*")    — write_report:27
[AUDIT] DENY   net("internal.shop.com")    — fetch_orders:34  (deny rule)
[AUDIT] GRANT  stdout                      — main:5
[AUDIT] GRANT  env("STRIPE_SECRET_KEY")    — main:8
Use --audit during development and code review to verify that a program only accesses the resources it claims to need. Combine with --secure for a complete picture of production behaviour.

Combining flags

syntecnia run program.syn --secure --audit

Both flags can be used together. Audit records are written to stderr so they do not interfere with program output.

11. CLI Grants

In environments where you cannot modify source code, you can grant capabilities from the command line using --grant. This is useful for deployment pipelines or wrapper scripts.

syntecnia run program.syn --grant net:api.example.com

Grant syntax

# Grant a scoped capability
syntecnia run program.syn --grant net:api.example.com

# Grant a path-scoped file capability
syntecnia run program.syn --grant file.read:/data/*

# Grant multiple capabilities
syntecnia run program.syn \
  --grant net:api.example.com \
  --grant file.write:/logs/* \
  --grant env:API_KEY

# Grant a no-argument capability
syntecnia run program.syn --grant stdout --grant time
CLI grants are added on top of the capabilities declared in the source file — they do not replace them. A deny in the source always wins, even against a --grant from the CLI.
CLI grants are recorded in the audit log with a [CLI] tag so you can easily distinguish them from source-level declarations.

12. Best Practices

Following these guidelines will keep your Syntecnia programs secure, auditable, and easy to reason about.

  • Use the minimum required capabilities. If a task only reads files, use file.read — not file. If you only need one endpoint, name it exactly rather than using a wildcard.
  • Prefer per-task sandboxing over program-level grants. Declare capabilities inside each task block rather than at the top of the program. This limits the blast radius if a task misbehaves.
  • Use invariants for critical business rules. Balance checks, quantity floors, price minimums — anything that must never be violated should be an invariant, not an inline if.
  • Run --secure in production. Never rely on auto-granted capabilities in a production environment.
  • Use --audit during development. Review the audit log before shipping to confirm the program's actual access pattern matches its declared intent.
  • Never rely on intent for security. Intent is documentation. Access control requires require, deny, or sandbox.
  • Prefer deny for known-bad resources. If a wildcard grant is necessary but certain sub-resources must be excluded, add explicit deny rules. deny always wins.
  • Use sandbox blocks for untrusted data processing. When you must handle input from an untrusted source, wrap the processing logic in a sandbox block to guarantee zero side-effects.

Quick reference

-- Minimal, well-structured capability declaration
intent: "Fetch customer orders and write a CSV report"

task fetch_orders()
    require net("api.shop.com")           -- narrowest possible net grant
    give fetch("https://api.shop.com/orders")

task write_csv(orders)
    require file.write("/reports/orders.csv")  -- one file, write-only
    write("/reports/orders.csv", to_csv(orders))

task main()
    require stdout
    invariant: orders != null

    let orders = fetch_orders()
    write_csv(orders)
    print("Report written.")
The goal is for a reader to understand exactly what resources a program uses by scanning its require and invariant declarations — without having to read the full implementation.