Security model
Threat model
Flowly's plugin runtime is intentionally trust-based. A plugin you load can:
- Read and write any file the Flowly process can
- Run arbitrary shell commands
- Make network requests to anywhere
- Read environment variables (including API keys)
- Read other plugins' state files at
$FLOWLY_HOME/<other-plugin>/
This is the same privilege level as a pip package or npm dependency. Treat plugin authors as you would any third-party dependency author — read the code or trust the source.
For plugin authors
The bargain Flowly offers: we don't sandbox you, but we expect you to be honest about what your plugin does and conservative about what it touches.
- Declare everything in your manifest.
provides_tools,provides_hooks,requires_env— these are the user's first line of trust. List every register call honestly. - Document side effects in your README. Network destinations, files written, env vars consumed. Users decide based on this.
- Default to scope-tight. Don't scan the entire home directory if you only need
$FLOWLY_HOME. Don't hit the network at register time. Don't read any file the user didn't explicitly point you at. - No surprises in updates. If v2 of your plugin starts doing something v1 didn't, document it loudly. Adding network access in a minor version is hostile.
For plugin users
When evaluating a plugin before installing:
- Read the manifest.
cat ~/.flowly/plugins/<name>/plugin.yaml(after install) or fetch it from the source repo. Look atprovides_toolsandprovides_hooks. - Skim the source. Plugin code is plain Python. Even a 30-second skim catches obvious red flags — outbound HTTP to unknown hosts, reading
~/.ssh/, eval()ing user input. - Prefer pinned versions.
flowly plugins install owner/repotracks the default branch. For production, pin to a specific commit or tag once you have a Git URL. - Audit before enabling.
installcopies files; the plugin only runs afterenable+ gateway restart. Use the gap to inspect.
disk-cleanup, auto-commit — are reviewed before each release and follow these guidelines. They're a reasonable baseline of "safe".Handling secrets
Many plugins need API keys or OAuth tokens. Some patterns to follow, some to avoid.
Do
- Read from env vars or a local credentials file under your plugin's state dir, mode
0600:pythoncreds_file = _get_state_dir() / "credentials.json" creds_file.parent.mkdir(parents=True, exist_ok=True) creds_file.write_text(json.dumps(creds)) creds_file.chmod(0o600) - Document required env vars in the manifest via
requires_env:yamlrequires_env: - name: ACME_API_KEY description: "API key from acme.example.com/dashboard" secret: true - Refresh tokens lazily. Detect expiration on the next API call and refresh; don't poll.
Don't
- Hardcode keys in your source — even "just for testing"
- Log credentials, even partially.
log.warning(f"auth failed: {token[:6]}...")ends up in audit files - Send credentials to anywhere except the intended service. No telemetry, no "help us debug" phone-home
- Read other plugins'
credentials.jsonfiles. Stay in your own state directory
Honest manifest declaration
The manifest is the user's primary trust signal. Be exhaustive:
name: my-crm-plugin
version: 1.2.0
description: "Look up accounts, log activities, and create tasks in our internal CRM."
author: "platform@acme.com"
kind: standalone
provides_tools:
- crm_lookup_account
- crm_log_activity
- crm_create_task
provides_hooks:
- post_tool_call # writes audit log of CRM-touching tool calls
requires_env:
- name: ACME_CRM_TOKEN
description: "Bearer token from CRM dashboard → Settings → API"
secret: true
- name: ACME_CRM_BASE_URL
description: "Your CRM instance URL (e.g. https://acme.crm.example.com)"
secret: falseA user reading this knows: three tools, one observability hook, two env vars (one secret), only network egress is to the CRM URL they provided. That's the trust contract.
Scope your filesystem and network
Operate on the smallest set of paths and hosts your feature requires. Some practical rules:
- Never write outside your state dir or a user-provided path. If your plugin tracks files, store the index in your own
$FLOWLY_HOME/<plugin>/, not the file's parent dir. - Allow-list, don't deny-list. Tell users which paths your plugin operates on (
auto-commitrequires explicit/addcalls). Don't scan home and skip "dangerous" subdirectories — that's a footgun. - Network egress to known hosts only. Hardcode the base URL of the service you integrate with, or read it from a user-provided env var. Don't accept arbitrary URLs.
- Timeouts always.
httpx.AsyncClient(timeout=10). A hung request blocks the agent loop.
Errors must not leak
Two failure modes to avoid: leaking secrets in error messages, and breaking the agent loop with unhandled exceptions.
# ❌ Bad — token may end up in an audit log seen by a different operator
try:
api.call(token=user_token)
except Exception as exc:
return f"API call failed: {exc}" # may include token in repr
# ✅ Good — generic message, full detail in your own log file with proper ACL
try:
api.call(token=user_token)
except Exception as exc:
_log({"event": "api_failed", "error": type(exc).__name__})
return "Failed to reach CRM. Check your token and try again."And on the loop side: every plugin entry point should swallow its exceptions:
def on_post_tool_call(ctx):
try:
# ... your hook logic
except Exception as exc:
_log({"event": "hook_error", "error": str(exc)})
# Return None — never raise. The agent must keep running.