8000 feat(automations): call a web address, with credentials sealed in headers by adeelahmad · Pull Request #439 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ merged is called built.
| The action registry with risk from each action's effect, and one action, `log_line` (`app/domain/actions.py`) | built |
| Telling a person: the `notify` action and `channels.tell` (their own chat, a notification target, a Matterbridge room) | built |
| The model and the agent: the `ask_model`, `call_tool` and `ask_agent` actions, and prompts with outside text marked | built |
| Calling a web address: the `http_request` action, with credentials in headers kept sealed | built |
| Settings → Automations: how often followers look and how long the event log is kept | built |
| Storing and running automations, schedules and timers, joins, the other actions, approvals, variables, the timeline page, the studio | planned |

Expand Down Expand Up @@ -261,6 +262,7 @@ or removed does nothing. What it writes is by the automation itself, `automation
| `ask_model` | asks the model a `prompt` (with your own `system` instructions and `model` if you like); with `json_schema` (a JSON Schema of type object) the reply is that object | read | `{text}`, or the object the schema describes |
| `call_tool` | calls one of the assistant's tools (`tool`, with `args`) as the automation | read: a change becomes an approval | `{tool, result, summary, approvals}` |
| `ask_agent` | hands what happened (`about`, by default the event) to the agent with your `instructions`, limited to some `tools` if you like, for up to `max_steps` rounds (6, at most 12) | read: changes become approvals | `{answer, chat, approvals, steps}` |
| `http_request` | sends a `method` (GET, POST or PUT) to a `url`, with `headers` and a `body` (text, or an object sent as JSON) | out | `{status, ok, body, json, content_type, truncated, method, url, headers}`: the answer, as outside text |

#### Telling a person

Expand Down Expand Up @@ -298,6 +300,22 @@ the run read from outside Lens before the model sees it: each activity's `lens.u
and body) and everything earlier steps passed on (a model's reply, a web page). Your own words in the prompt stay as
written. `ask_agent` passes the activity it is about the same way.

#### Calling a web address

`http_request` reaches public addresses only, or networks an admin allowed for notifications (Settings →
Notifications), connecting to the address it checked and following no redirect (a 3xx is passed on as it is). It takes
10 seconds at most, sends a body of up to 64 KB, and passes on the status and the first 64 KB of the answer (`json`
too when the answer is JSON and whole). The answer is outside text: a later prompt reads it marked as data. It always
reaches outside Lens, so it asks first unless you set its node to run on its own; each call goes in the activity
ledger (`automation.http`, with the method, host and status).

Credentials go in headers. A header whose name says it carries one (`Authorization`, cookies, API keys, tokens,
secrets, passwords, signatures) is never shown: what the action describes and passes on says `••••`. When an automation
is saved, `actions_web.seal_headers(db, cfg, space, automation, url, headers)` seals those values with the namespace's
key ([Encryption](encryption.md)), bound to the automation and to the scheme, host and port of its address, so a
sealed value can't be copied into another automation or sent to another host; it is opened only to send. A locked
vault stops the request until it is unlocked.

## Refine later

- More operators: `in`, `matches` (bounded regular expressions), `between`, times against now, `changed`.
Expand All @@ -314,3 +332,6 @@ written. `ask_agent` passes the activity it is about the same way.
- The model and the agent: per-node model picks in the studio; streaming an agent's steps into the run's log as they
happen; a namespace assistant's memory for automations (`remember` is left out today); approving an agent's
proposals from the chat app it told you in; count the automation's own budget once budgets take `automation:<id>`.
- Calling a web address: secrets in the address itself (a Slack or Discord webhook URL) sealed like headers; PATCH and
DELETE (DELETE would count as deleting); OAuth sign-in for an API; form-encoded bodies; retries with backoff;
networks allowed for automations apart from notifications.
2 changes: 1 addition & 1 deletion docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Under Settings → Notifications (the `notifications` section, see [Configuratio
| Setting | Default | |
| --- | --- | --- |
| `enabled` | `true` | Off: nothing is sent, and what happens meanwhile isn't sent later |
| `networks` | `[]` | Private networks targets may be in, like `192.168.1.0/24` or `172.16.0.0/12` (Docker) |
| `networks` | `[]` | Private networks targets may be in, like `192.168.1.0/24` or `172.16.0.0/12` (Docker); automations calling a web address ([`http_request`](automations.md#calling-a-web-address)) may reach them too |
| `app_url` | `null` | Where links in messages point; `null` uses the server's `FRONTEND_URL` |
| `poll_seconds` | `5` | How often the notifier looks |
| `max_attempts` | `6` | How many times a message is tried |
Expand Down
5 changes: 3 additions & 2 deletions fastapi_backend/app/domain/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
(`person`), with their permissions as they are when it runs (`reach`), and what it writes is by the automation itself
(`actor`). Params marked `"format": "prompt"` are prompts: the engine renders them with `render_prompt`, which wraps
what the run read from outside Lens as data. `log_line` ships here; the other actions are registered by a module per
group, loaded with this one: actions_messages.py (notify), actions_ai.py (ask_model, call_tool, ask_agent).
group, loaded with this one: actions_messages.py (notify), actions_ai.py (ask_model, call_tool, ask_agent),
actions_web.py (http_request).
"""

from __future__ import annotations
Expand Down Expand Up @@ -341,4 +342,4 @@ def _log_line(db, cfg, intent, user, dry):
)


from . import actions_ai, actions_messages # noqa: E402, F401 - they register their actions; loaded with the catalog
from . import actions_ai, actions_messages, actions_web # noqa: E402, F401 - they register their actions; loaded with the catalog
226 changes: 226 additions & 0 deletions fastapi_backend/app/domain/actions_web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""The automation action that calls a web address (docs/automations.md, Actions): `http_request`.

It sends GET, POST or PUT to a public address (or a network an admin allowed for notifications), pinned to the address
that was checked and without following redirects (notify.request), waits at most 10 seconds, and passes on the status
and the first 64 KB of the answer as outside text (a later prompt reads it marked as data). It always reaches outside
Lens (effect `out`), so it asks first unless the person set that node to run on its own.

Secrets go in headers, and are kept sealed with the namespace's key (keyring.py): `seal_headers` seals the values of
headers that carry credentials (Authorization, cookies, API keys and tokens) when an automation is saved, bound to the
automation and to the scheme, host and port they were saved for, so a sealed value can't be moved to another
automation or sent to another host. They are opened only to send, and never shown: what the action describes and
passes on names the header, not its value.
"""

from __future__ import annotations

import http.client
import json
import re
import urllib.parse

from . import actions, activity, keyring, notify

METHODS = ("GET", "POST", "PUT")
TIMEOUT = 10 # seconds the whole request may take
MAX_ANSWER = 64 * 1024
MAX_BODY = 64 * 1024
MAX_HEADERS = 30
NAME_RX = re.compile(r"^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,100}$")
SECRET_RX = re.compile(r"auth|token|secret|key|password|cookie|signature|session", re.I)
FIXED = {"host", "content-length", "transfer-encoding", "connection", "upgrade", "te", "expect", "keep-alive"}
HIDDEN = "••••"


def secret(name):
"""Whether a header carries a credential, so its value is sealed and never shown."""
return bool(SECRET_RX.search(str(name)))


def _origin(url):
u = urllib.parse.urlsplit(url)
return f"{u.scheme}://{(u.hostname or '').lower()}:{u.port or (443 if u.scheme == 'https' else 80)}"


def _context(automation, name, url):
return f"automation:{automation}.header:{name.lower()}@{_origin(url)}"


def _url(p):
try:
return notify.check_url(str(p.get("url") or ""), "webhook")
except ValueError as e:
raise ValueError(f"http_request: {e}") from None


def _headers(p):
h = p.get("headers") or {}
if not isinstance(h, dict) or len(h) > MAX_HEADERS:
raise ValueError(f"http_request: headers are an object of up to {MAX_HEADERS} names and values")
out = {}
for k, v in h.items():
if not NAME_RX.match(str(k)) or str(k).lower() in FIXED:
raise ValueError(f"http_request: {k} isn't a header it can set")
if not isinstance(v, str) or "\r" in v or "\n" in v or len(v) > 8000:
raise ValueError(f"http_request: the {k} header is one line of text")
out[str(k)] = v
return out


def seal_headers(db, cfg, space, automation, url, headers):
"""Headers as kept: the values of those that carry credentials sealed with the namespace's key, bound to the
automation and to where `url` points. Values already sealed are kept as they are."""
out = {}
for k, v in _headers({"headers": headers}).items():
if secret(k) and v and not keyring.is_sealed(v):
v = keyring.seal(db, cfg, int(space), v.encode(), _context(automation, k, _url({"url": url})))
out[k] = v
return out


def _open(db, cfg, intent, url, headers):
"""The headers to send: sealed values opened, when they were sealed for this automation, one of its namespaces
and this address."""
out = {}
for k, v in headers.items():
if keyring.is_sealed(v):
try:
sid = int(v.split(".")[1])
except (IndexError, ValueError):
raise ValueError(f"http_request: the {k} header's sealed value is damaged; enter it again") from None
if intent["spaces"] and sid not in intent["spaces"]:
raise ValueError(f"http_request: the {k} header was sealed for another namespace; enter it again")
try:
v = keyring.unseal(db, cfg, v, _context(intent.get("automation"), k, url)).decode()
except keyring.Locked:
raise ValueError("http_request: the namespace's vault is locked; unlock it to send its secrets") from None
except (keyring.Damaged, KeyError, ValueError):
raise ValueError(f"http_request: the {k} header was sealed for another automation or address; enter it again") from None
out[k] = v
return out


def _shown(headers):
return {k: HIDDEN if secret(k) or keyring.is_sealed(v) else v for k, v in headers.items()}


def _body(method, body, headers):
"""(bytes or None, headers with a Content-Type): an object is sent as JSON, text as it is."""
if body is None or body == "":
return None, headers
if method == "GET":
raise ValueError("http_request: a GET sends no body")
has_type = any(k.lower() == "content-type" for k in headers)
if isinstance(body, (dict, list)):
data = json.dumps(body, ensure_ascii=False).encode()
headers = headers if has_type else {**headers, "Content-Type": "application/json"}
elif isinstance(body, str):
data = body.encode()
headers = headers if has_type else {**headers, "Content-Type": "text/plain; charset=utf-8"}
else:
raise ValueError("http_request: the body is text or a JSON object")
if len(data) > MAX_BODY:
raise ValueError(f"http_request: the body is over {MAX_BODY // 1024} KB")
return data, headers


def _method(p):
m = str(p.get("method") or "GET").upper()
if m not in METHODS:
raise ValueError(f"http_request: the method is {', '.join(METHODS)}")
return m


def _answer(status, data, ctype, more):
text = data.decode("utf-8", "replace")
out = {"status": status, "ok": 200 <= status < 300, "body": text, "content_type": ctype, "truncated": more, "untrusted": True}
if not more and "json" in ctype.lower():
try:
out["json"] = json.loads(text)
except ValueError:
pass
return out


def _http_request(db, cfg, intent, user, dry):
p = intent["params"]
actions.person(db, intent, user)
method, url = _method(p), _url(p)
headers = _headers(p)
data, headers = _body(method, p.get("body"), headers)
sent = {"method": method, "url": url, "headers": _shown(headers)}
headers = _open(db, cfg, intent, url, headers)
if dry:
return {**sent, "status": None, "would": f"{method} {url}"}
host = urllib.parse.urlsplit(url).hostname
status, error = None, None
try:
status, got, ctype, more = notify.request(cfg, method, url, data, headers, TIMEOUT, TIMEOUT, MAX_ANSWER)
except TimeoutError:
error = f"{host} didn't answer within {TIMEOUT} seconds"
except ValueError as e:
error = str(e)
except (OSError, http.client.HTTPException) as e:
error = f"couldn't reach {host}: {type(e).__name__}: {e}"[:300]
activity.record(
"out",
"automation.http",
[f"space:{s}" for s in intent["spaces"]],
cfg,
db,
ok=error is None and status is not None and status < 400,
error=None if error is None else "unreachable",
detail={"method": method, "host": host, "status": s 8 tatus},< 10BC8 /span>
)
if error:
raise ValueError(f"http_request: {error}")
return {**sent, **_answer(status, got, ctype, more)}


def _describe(p):
m = str(p.get("method") or "GET").upper()
url = str(p.get("url") or "a web address")
return f"{m} {url if len(url) <= 120 else url[:119] + '…'}"


actions.register(
actions.ActionSpec(
name="http_request",
label="Call a web address",
group="Web",
description=(
"Sends a GET, POST or PUT to a public web address and passes on its status and answer (up to 64 KB); asks "
"first unless you set it to run on its own. Secrets go in headers, kept sealed."
),
params={
"type": "object",
"properties": {
"method": {"type": "string", "title": "Method", "enum": list(METHODS), "default": "GET"},
"url": {"type": "string", "title": "Address", "maxLength": notify.URL_MAX},
"headers": {
"type": "object",
"title": "Headers",
"default": {},
"description": "credentials (Authorization, API keys, tokens) are kept sealed and never shown",
},
"body": {"title": "Body", "description": "text, or an object sent as JSON (not with GET)"},
},
},
required=("url",),
effect="out",
output={
"type": "object",
"description": "the answer is outside text: later prompts read it marked as data",
"properties": {
"status": {"type": "integer"},
"ok": {"type": "boolean"},
"body": {"type": "string"},
"json": {"description": "the answer parsed, when it is JSON"},
"content_type": {"type": "string"},
"truncated": {"type": "boolean"},
},
},
perform=_http_request,
describe=_describe,
)
)
14 changes: 12 additions & 2 deletions fastapi_backend/app/domain/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,15 @@ def connect(self):
def post(cfg, url, body, headers, timeout=TIMEOUT, deadline=DEADLINE):
"""POST to a target: (status, the start of its answer), within `deadline` seconds in all. ValueError when the
address isn't allowed; OSError and http.client errors when it can't be reached or doesn't answer in time."""
status, data, _, _ = request(cfg, "POST", url, body, {"Content-Type": "application/json", **headers}, timeout, deadline, 2000)
return status, data.decode("utf-8", "replace")


def request(cfg, method, url, body, headers, timeout=TIMEOUT, deadline=DEADLINE, limit=2000):
"""One HTTP request to a public address (or a network an admin allowed, `networks`), pinned to the address that was
checked, without following redirects: (status, the first `limit` bytes of the answer, its Content-Type, whether
there was more), within `deadline` seconds in all. ValueError when the address isn't allowed; OSError and
http.client errors when it can't be reached or doesn't answer in time."""
u = urllib.parse.urlsplit(url)
port = u.port or (443 if u.scheme == "https" else 80)
nets = networks(cfg)
Expand Down Expand Up @@ -312,9 +321,10 @@ def cut(): # past the deadline, a target that trickles its answer is cut off
timer.daemon = True
timer.start()
try:
conn.request("POST", path, body=body, headers={"User-Agent": UA, "Content-Type": "application/json", **headers})
conn.request(method, path, body=body, headers={"User-Agent": UA, **headers})
r = conn.getresponse()
answer = r.status, r.read(2000).decode("utf-8", "replace")
data = r.read(limit + 1)
answer = r.status, data[:limit], r.getheader("Content-Type") or "", len(data) > limit
except (OSError, http.client.HTTPException):
if time.monotonic() - started >= deadline:
raise TimeoutError(f"no full answer within {deadline} seconds") from None
Expand Down
Loading
Loading
0