Files
LocalAI/addons/home-assistant/internal/tools/write.go
T
Ettore Di Giacinto 6be3a96083 feat(addons): add the Home Assistant add-on
Chats and agents can read a Home Assistant instance and, if the person
allows it, switch things in it.

Read tools: server summary, areas, entity search, state, history,
logbook and the catalogue of callable services. Results are bounded,
marked as untrusted data, and stripped of pictures, access tokens,
secrets and locations.

Write tools: call-service for quiet services (lights, switches, input
booleans, media transport) follows the write mode of the connection;
call-physical-service always needs a confirmation. A deny-by-default
allowlist decides which services a model may call. A hard exclusion
list that no setting can lift covers locks, alarms, sirens, valves,
cameras, garage-type covers, restarts, reloads and shell, system and
add-on management. Calls name entities one by one, up to a cap.
Entities can be excluded by domain, area, pattern or label.

One connection is one instance: the token is the credential and the
address is its one allowed resource, so the host refuses any other
address before the add-on runs. The add-on speaks only to that address,
refuses redirects, verifies TLS, and accepts a self-signed certificate
only through a pinned SHA-256 fingerprint.

The policy lives in the add-on data folder and is edited on the add-on
page by an administrator. For that, the host no longer asks a
credential for a uiOnly tool of a credentialed add-on, since the page
has no chat to name a connection.

Assisted-by: Claude:claude-sonnet-5-5 go node
2026-11-04 15:08:10 +00:00

381 lines
13 KiB
Go

// SPDX-License-Identifier: MIT
package tools
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/mudler/LocalAI/addons/home-assistant/internal/haapi"
"github.com/mudler/LocalAI/addons/home-assistant/internal/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The two write tools are one operation with two confirmation levels. The host
// decides from the write mode of the connection whether call-service is
// refused, stored as a proposal for a person, or applied at once.
// call-physical-service declares approval "always" in the manifest: a person
// confirms every call, whatever the write mode is. A service goes to the tool
// that matches its class (policy.Physical), and the other tool refuses it.
const (
maxDataBytes = 2 << 10
maxDataDepth = 3
maxDataString = 200
maxDataItems = 20
previewBudget = 3 << 10
quietExpiry = 2 * time.Hour
physicalExpiry = 15 * time.Minute
)
var (
nameShape = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
// targetKeys inside data would add targets that the cap and the entity checks never saw.
targetKeys = map[string]bool{"entity_id": true, "device_id": true, "area_id": true, "floor_id": true, "label_id": true}
)
type writeArgs struct {
Instance string `json:"instance"`
Domain string `json:"domain"`
Service string `json:"service"`
EntityID []string `json:"entityIds"`
Data map[string]any `json:"data"`
}
type write struct {
tool string
writeArgs
}
func checkData(v any, depth int) error {
if depth > maxDataDepth {
return bad("data is nested too deep")
}
switch x := v.(type) {
case nil, bool, float64:
return nil
case string:
if len(x) > maxDataString {
return bad("a text in data has at most %d characters", maxDataString)
}
if strings.Contains(x, "{{") || strings.Contains(x, "{%") || strings.Contains(x, "{#") {
return bad("data may not hold template markup")
}
return checkText("a text in data", x, maxDataString)
case []any:
if len(x) > maxDataItems {
return bad("a list in data has at most %d items", maxDataItems)
}
for _, e := range x {
if err := checkData(e, depth+1); err != nil {
return err
}
}
return nil
case map[string]any:
if len(x) > maxDataItems {
return bad("data has at most %d keys", maxDataItems)
}
for k, e := range x {
if !nameShape.MatchString(k) || targetKeys[k] {
return bad("data key %q is not allowed", short(k, 30))
}
if err := checkData(e, depth+1); err != nil {
return err
}
}
return nil
}
return bad("data holds a value that is not JSON")
}
// parseWrite checks the arguments of a write tool against the policy.
func (c *call) parseWrite(tool string, raw map[string]json.RawMessage) (write, error) {
var a writeArgs
if err := decodeArgs(raw, &a); err != nil {
return write{}, err
}
w := write{tool: tool, writeArgs: a}
if err := c.instance(a.Instance); err != nil {
return w, err
}
if !nameShape.MatchString(a.Domain) || !nameShape.MatchString(a.Service) {
return w, bad("domain and service are words like light and turn_on")
}
if policy.HardService(a.Domain, a.Service) {
return w, bad("%s.%s can never be called by this add-on: locks, alarms, sirens, valves, cameras, restarts, reloads, and shell, system and add-on management are not available to a model", a.Domain, a.Service)
}
if !c.pol.ServiceAllowed(a.Domain, a.Service) {
return w, bad("%s.%s is not on the allowlist of the add-on. An administrator can allow it on the add-on page", a.Domain, a.Service)
}
phys := policy.Physical(a.Domain, a.Service)
switch {
case phys && tool == ToolCallService:
return w, bad("%s.%s changes something physical or runs automation: use %s, which a person confirms each time", a.Domain, a.Service, ToolCallPhysical)
case !phys && tool == ToolCallPhysical:
return w, bad("%s.%s is a quiet service: use %s", a.Domain, a.Service, ToolCallService)
}
if n := len(a.EntityID); n < 1 || n > c.pol.MaxTargets {
return w, bad("name 1 to %d entities in entityIds. Area, device and all targets are not accepted", c.pol.MaxTargets)
}
seen := map[string]bool{}
for _, id := range a.EntityID {
if err := checkEntity(id); err != nil {
return w, err
}
if seen[id] {
return w, bad("entityIds names %s twice", id)
}
seen[id] = true
if d, _, _ := strings.Cut(id, "."); d != a.Domain {
return w, bad("%s is not an entity of the domain %s", id, a.Domain)
}
if c.pol.EntityExcluded(id) {
return w, bad("%s is excluded by the policy of the add-on", id)
}
}
if a.Data != nil {
b, _ := json.Marshal(a.Data)
if len(b) > maxDataBytes {
return w, bad("data is longer than %d bytes", maxDataBytes)
}
if err := checkData(a.Data, 1); err != nil {
return w, err
}
}
return w, nil
}
type target struct {
EntityID string `json:"entityId"`
Name string `json:"name"`
State string `json:"state"`
}
func sum64(parts ...string) uint64 {
h := sha256.New()
for _, p := range parts {
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(p)))
h.Write(l[:])
h.Write([]byte(p))
}
return binary.BigEndian.Uint64(h.Sum(nil)[:8]) | 1
}
// inspect reads each target and checks it. The revision holds what makes the
// call wrong if it changed: the entities, the service and the policy. The state
// of a light is not part of it, since it moves all the time.
func (c *call) inspect(w write) ([]target, uint64, error) {
hid, err := c.hidden()
if err != nil {
return nil, 0, err
}
out := make([]target, 0, len(w.EntityID))
for _, id := range w.EntityID {
if !c.visible(id, hid) {
return nil, 0, bad("%s is excluded by the policy of the add-on", id)
}
var s haState
if err := c.get("/api/states/"+url.PathEscape(id), nil, &s); err != nil {
if errors.Is(err, haapi.ErrNotFound) {
return nil, 0, bad("%s does not exist", id)
}
return nil, 0, err
}
if w.Domain == "cover" {
dc, _ := s.Attributes["device_class"].(string)
if policy.HardCover(dc) {
return nil, 0, bad("%s is a door, a gate, a garage door or a cover with no known type: this add-on never moves it", id)
}
}
out = append(out, target{EntityID: id, Name: friendly(s.Attributes, id), State: short(s.State, 40)})
}
ids := append([]string(nil), w.EntityID...)
sort.Strings(ids)
parts := append([]string{"svc", w.Domain, w.Service, c.raw.Hash()}, ids...)
return out, sum64(parts...), nil
}
func plainJSON(v any) json.RawMessage {
var b bytes.Buffer
e := json.NewEncoder(&b)
e.SetEscapeHTML(false)
_ = e.Encode(v)
return bytes.TrimSpace(b.Bytes())
}
// sample is what the person sees before a call: facts built from the
// arguments and the states read from Home Assistant, never from model prose.
func sample(w write, ts []target, dataCut, nameCut int) map[string]any {
rows := make([]map[string]any, 0, len(ts))
for _, t := range ts {
rows = append(rows, map[string]any{"entityId": t.EntityID, "name": short(t.Name, nameCut), "state": short(t.State, 30)})
}
s := map[string]any{"tool": w.tool, "instance": w.Instance, "domain": w.Domain, "service": w.Service,
"action": w.Domain + "." + w.Service, "physical": policy.Physical(w.Domain, w.Service), "targets": rows}
if len(w.Data) > 0 {
b := plainJSON(w.Data)
d, cut := runes(string(b), dataCut)
s["data"] = d
if cut {
s["dataCut"] = true
}
}
return s
}
func (r *Runtime) prepare(ctx context.Context, env contract.Envelope) (write, *call, error) {
tool, args, err := splitArgs(env)
if err != nil {
return write{}, nil, err
}
if !isWrite(tool) {
return write{}, nil, bad("unknown tool")
}
if env.Request.Credential == "" {
return write{}, nil, bad("no token for this call")
}
c, err := r.newCall(ctx, env)
if err != nil {
return write{}, nil, err
}
w, err := c.parseWrite(tool, args)
return w, c, err
}
// Preview checks a write and describes it. It changes nothing.
func (r *Runtime) Preview(ctx context.Context, env contract.Envelope) (contract.Preview, error) {
w, c, err := r.prepare(ctx, env)
if err != nil {
return contract.Preview{}, translate(err)
}
ts, rev, err := c.inspect(w)
if err != nil {
return contract.Preview{}, translate(err)
}
summary := plainJSON(map[string]any{"count": len(ts), "sample": sample(w, ts, 500, 60)})
if len(summary) > previewBudget {
summary = plainJSON(map[string]any{"count": len(ts), "sample": sample(w, ts, 120, 24)})
}
if len(summary) > previewBudget {
return contract.Preview{}, bad("the preview is too large")
}
exp := quietExpiry
if policy.Physical(w.Domain, w.Service) {
exp = physicalExpiry
}
return contract.Preview{OperationID: env.Request.OperationID, Revision: contract.Revision{Schema: 1, Data: rev},
ExpiresAt: r.now().Add(exp), Summary: summary}, nil
}
// Receipt answers from the journal. An operation that was started and not
// finished is unknown: the add-on cannot tell whether Home Assistant got it.
func (r *Runtime) Receipt(_ context.Context, op string) (contract.Receipt, error) {
if rc, ok := r.journal().receipt(op); ok {
return rc, nil
}
return contract.Receipt{OperationID: op, State: contract.ReceiptUnknown}, nil
}
// Apply makes the call once. Home Assistant has no idempotency key, and a call
// such as toggle is not safe to repeat, so the add-on keeps a "started" note
// before it sends and never sends an operation twice: an operation that was
// started and has no receipt is unknown, and a person decides.
func (r *Runtime) Apply(ctx context.Context, env contract.Envelope, rev contract.Revision) (contract.Receipt, error) {
op := env.Request.OperationID
r.applyMu.Lock()
defer r.applyMu.Unlock()
j := r.journal()
if rc, ok := j.receipt(op); ok {
return rc, nil
}
if _, started := j.started(op); started {
return contract.Receipt{OperationID: op, State: contract.ReceiptUnknown, Revision: rev}, nil
}
w, c, err := r.prepare(ctx, env)
if err != nil {
return contract.Receipt{}, translateWrite(err)
}
_, now, err := c.inspect(w)
if err != nil {
return contract.Receipt{}, translateWrite(err)
}
if now != rev.Data {
rc := contract.Receipt{OperationID: op, State: contract.ReceiptRejected, Revision: rev,
Result: contract.Result{Payload: smallResult(map[string]any{"stale": true, "note": "The entities or the policy changed after the preview. Nothing was sent."})}}
return rc, j.commit(rc)
}
if err := j.begin(op, r.now()); err != nil {
return contract.Receipt{}, err
}
out, err := c.send(w)
switch {
case err == nil:
case errors.Is(err, haapi.ErrUnreadable):
out = map[string]any{"note": "sent; the answer of Home Assistant was not readable"}
case definite(err):
j.forget(op)
return contract.Receipt{}, translateWrite(err)
default:
// The request may or may not have arrived. The host asks for the
// receipt, gets "unknown", and leaves the decision to the person.
return contract.Receipt{}, err
}
rc := contract.Receipt{OperationID: op, State: contract.ReceiptCommitted, Revision: rev, Result: contract.Result{Payload: smallResult(out)}}
return rc, j.commit(rc)
}
func smallResult(data map[string]any) json.RawMessage {
return plainJSON(map[string]any{"source": SourceName, "untrusted": true, "data": data})
}
// definite reports an error that proves Home Assistant did not make the change.
func definite(err error) bool {
var limited *contract.ErrRateLimited
return errors.As(err, &limited) || errors.Is(err, haapi.ErrUnauthorized) || errors.Is(err, haapi.ErrForbidden) ||
errors.Is(err, haapi.ErrNotFound) || errors.Is(err, haapi.ErrInvalid) || errors.Is(err, haapi.ErrCertificate) || errors.Is(err, haapi.ErrBadAddress)
}
func translateWrite(err error) error {
var limited *contract.ErrRateLimited
if errors.As(err, &limited) {
return bad("home assistant is limiting calls; nothing was sent. Propose it again in %d seconds", limited.Seconds())
}
return translate(err)
}
// send makes the call. The answer of Home Assistant lists the states it
// changed with all their attributes; only the entity and the new state of the
// named targets are kept.
func (c *call) send(w write) (map[string]any, error) {
body := map[string]any{}
for k, v := range w.Data {
body[k] = v
}
body["entity_id"] = w.EntityID
var changed []haState
if err := c.r.API.PostJSON(c.ctx, c.target, c.token, "/api/services/"+w.Domain+"/"+w.Service, body, &changed); err != nil {
return nil, err
}
asked := map[string]bool{}
for _, id := range w.EntityID {
asked[id] = true
}
rows := []map[string]any{}
for _, s := range changed {
if asked[s.EntityID] && len(rows) < policy.MaxTargetsCap {
rows = append(rows, map[string]any{"entityId": s.EntityID, "state": short(s.State, 40)})
}
}
return map[string]any{"service": w.Domain + "." + w.Service, "entities": len(w.EntityID), "states": rows}, nil
}