Feral-ai

Latest version: v2026.8.11

Safety actively analyzes 993455 Python packages for vulnerabilities to keep your Python projects secure.

Scan your dependencies

Page 1 of 16

2026.8.11

Two themes. Things that reported success without checking anything, and a
third-party path that could not be walked end to end by anyone outside
this repo.

BREAKING

- **`POST /api/marketplace/install` and `POST /api/apps/install` now
require an `install_token`** from the matching `/preview`, and answer
403 without one. Anything scripted against the old shape must take the
two steps. `feral install` and `feral app install` do this for you.

Fixed

- **A third-party skill could declare itself safe and never prompt.**
`safety_resolver` returned a manifest's own `safety_tier` before
consulting the danger map, and the only gate above it was a deny list of
first-party tool names a third-party id cannot match. So an installed
skill declaring `safety_tier: "safe"` executed with no confirmation, on
every surface, indefinitely. The same clamp `result_budget` already
applied to a less important field now applies here: a third party may
escalate, never de-escalate. There were two doors, not one; `is_read_only`
skips approval outright under `FERAL_AUTONOMY=strict` and is clamped too.

- **Installing an app silently installed code.** An `AppManifest` declares
`skill_dependencies`, and the install path resolved them through the
unverified developer path whose own log line reads `UNVERIFIED INSTALL`.
Skills execute Python in-process at boot. Both install paths now preview,
disclose the transitive skill set in three buckets, and bind a
single-use token to the sha256 of the verified tarball, so what was
agreed to and what installs are provably the same artifact.

- **The sandbox policy had one enforcement call site.**
`can_use_actuator`, `can_capture_camera`, `can_use_mcp_server` and
`can_access_domain` had none, so cameras, actuators, MCP servers and the
HTTP domain allowlist failed open with a complete and correct policy,
because nothing asked. Four also failed open on a partial policy, where
an empty allowlist meant allow-everything while `can_access_domain` in
the same class meant allow-nothing.

- **The desktop app shipped a virtualenv pointing at the build machine.**
`uv python find` resolves the ambient project environment before the
managed install, so the staged interpreter was the repo's own `.venv`:
`pyvenv.cfg` naming an absolute home, `lib/python3.11/` holding only
site-packages, no stdlib. The shipped app loaded its standard library
from the builder's home directory and could not start anywhere else. It
was self-perpetuating, because the reuse guard matched on version alone.

- **The release wheel smoke test was satisfied by the failure it existed
to catch.** Its root assertion was "200, contains FERAL and v2, lacks
leaflet", and the page served when the wheel ships *without* the v2
bundle satisfies all three. It now reads the hashed entry points out of
`index.html` and fetches each over HTTP.

- **`feral publish --skill` could not publish.** Three stacked blockers:
the posted manifest had no `kind` or `name`, the signature covered the
raw digest where every verifier uses hex-ASCII, and `--daemon` lacked
`node_id`. The second was masked by the first and was documented as
already-correct in a comment on a sibling file.

- **The registry could not be asked for an item by name.**
`/api/v1/item/{ref}` resolved only the UUID primary key while every
caller passes a name, so every declared skill dependency resolved as
"not published" and every app installed degraded. It now resolves id
then name, answers ambiguity explicitly (409 across kinds, highest
version within one kind, 409 for version sets that cannot be ordered)
rather than guessing.

- **The skill validator rejected every skill containing Python.** It
scanned raw source for dangerous calls with a substring test, and `exec`
is a substring of `execute`, the mandatory entry point of every skill.
28 of 29 shipped skills were flagged, 25 solely on that collision. Since
the validator gates the Marketplace preview, the web install and every
app dependency refused any real skill. It now matches call nodes.

- **`feral doctor` reported voice healthy from a key's existence.** An
install whose key had been rotated printed red "key rejected by API" in
one section and green "Voice runtime: key set" four blocks later.

- **A corrupt settings file silenced the network-exposure warning.** The
bind-host read substituted `""` on any error and logged nothing, and
empty reads as loopback-safe, so the one warning that says this brain is
reachable from the network with authentication off disappeared.

- Four more silent degradations now say what was lost: the probe sweeper
failing to start, the default-namespace vault fallback, an `ImportError`
from FERAL's own code filed as "missing dependency", and the boot
report's advice to install something no install could fix.

Changed

- **`make lint` lints.** It ran pytest with `2>/dev/null || true`,
reported success unconditionally including when every test failed, and
linted nothing. **`make test` runs both suites**; it ran only the Python
side, so changing a page and running it gave a green result that had not
executed one line of the change. `ruff` is declared in `[dev]` rather
than installed inline by CI.

- **The mypy ratchet can fail.** It had three stacked levers making it
advisory, and a crashed mypy produced empty output that counted as zero
errors and read as "all 683 fixed". The audits no longer launder their
own exit codes.

- **Four committed test suites now run in CI**: the 13 client e2e specs
(whose config cited a workflow file that has never existed),
`feral-extension`, `ts-node-sdk`, and the Mintlify nav check.

- `make dev-deps` installs `feral-client-v2`, which it never did, and
downloads the Playwright browser, which `npm install` does not fetch.
Two e2e specs had been unrunnable and the rest borrowed the system
Chrome.

- `make test-py` echoes its seed, so a shuffled failure can be replayed
with `make test-py PYTEST_SEED=N`.

Known issues

- Two order-dependent test failures remain, in `test_cli_repl_websockets`
and `test_embeddings_local_first`. Both pass in isolation. They are
tracked as real bugs rather than filed as flaky, and the seed change
above is what makes them reproducible.
- `MarketplaceClient.update` remains unverified and unconsented: an update
can widen a skill's permissions with nobody asked. Install is gated;
update is not.
- The desktop app builds on no automatic trigger, and `scripts/install.sh`,
the installer users actually run, is executed by no CI job.

Coverage
- pytest (feral-core): 8157 collected, 8126 passed, 31 skipped.
- pytest (feral-registry): 91 passed.
- vitest (feral-client-v2): 848 passed across 125 files.
- playwright e2e: 13 passed on chromium.

2026.8.10

An audit of every page and component in the web client and the desktop
shell, against the backend routes they actually call. The recurring defect
was one thing wearing different clothes: the UI making an affirmative
statement about something it had not verified. A failed request rendering
as an empty result, an indicator hardcoded to green, a control reporting
success without reading the answer.

Fixed

- **The kill switch reported "paused" without checking that it paused.**
`togglePause` set its state from the click, before the request, and threw
the server's answer away. `POST /api/supervisor/pause` returns the real
`{"paused": ...}`, and `_require_supervisor` raises 503 when the
Supervisor never initialised. On that 503 the await rejected, the refresh
never ran, and the pill read "Paused: yes" permanently while nothing was
halted. A safety control claiming every outgoing action is stopped, when
none is, is worse than one that says nothing.

- **A failed request rendered as an empty result, in nineteen places.**
`apiJson` throws on any non-2xx, and page after page caught that and set
state to an empty value, so "we could not ask" was displayed as "there is
nothing". The health page turned five separate fetch failures into
affirmative all-clears including **"No anomalies detected"**. The audit
log rendered "no events" when the audit backend was unreachable. Forge,
Intents and Agents went further and fabricated zeros, presenting invented
numbers in the same tiles that normally carry real measurements.

Fixed structurally rather than nineteen times: `useResource` holds one
rule, that `data` is only ever what the brain returned and is untouched
on failure, so a page cannot render an empty state off a failed fetch.
`ErrorState` is deliberately impossible to mistake for `EmptyState`.

- **The camera indicator never appeared and the mic mute did not mute.**
The hook kept state per component, and the floating chip and the pane are
separate mounts, so the chip never learned a share had started. The audio
handler captured the mute flag once at startup, so toggling it mid-stream
changed nothing while the button rendered as muted. `pause()` was worse:
it hid the chip and stopped video while leaving audio streaming. The
component's own text promised "there is no hidden-share mode". There was.
Muting video now also disables the track, so the camera light goes out.

- **A dead brain showed a pulsing green Devices dot.** Once one poll
succeeded the health store's retained payload made the error branch
unreachable, so a stopped brain read "reconnecting..." forever while tiles
rendered live counts from cache. Stale tiles now keep their numbers, drop
the pulse, and carry an "as of HH:MM:SS" stamp.

- **The Bluetooth tab reported a pair it never made.** It opened the browser
chooser and fired `onPaired`: no connection, no call to the brain, nothing
persisted. It cannot be implemented from that screen, because a
`BluetoothDevice` handle cannot claim a pairing token and the console is
not a HUP node, so it is now an honest "Bluetooth check" that says the
result is not registered.

- **The desktop shell could not say why the brain failed.** The splash
polled forever with no timeout and no failure branch, and the spawned
brain's stdout and stderr were piped and read by nothing. A traceback, a
missing FTS5 module and a port conflict were all the same symptom: a
health dot that never turned green. The wait is bounded and the failure
screen now shows what the brain actually printed.

- **The desktop setup form collected a Brain URL and API key and discarded
both.** No Rust command accepts either, and the app's own CSP allows
localhost only, so a remote brain could never have worked. The fields are
gone and the stored key is wiped from existing installs.

- **The floating quick-ask window had never once loaded.** Declared in the
Tauri config and toggled by both a global shortcut and the tray, while
Vite only ever built `index.html`, so both paths opened a 404.

- **The safety-policy editor swallowed every failure, and its backend
accepted anything.** Malformed JSON and a rejected request landed in the
same empty catch, while the route did `SandboxPolicy(body); save()` with
no validation and returned `{"ok": true}`. Validation now rejects the
cases that silently widen the sandbox, including `allow_shell_commands`
as the truthy string `"false"` and a misspelled `network.mode`, which
disabled allowlist enforcement entirely.

- **Status was carried by hue alone**, four identical dots differing only in
colour, which green-red colourblind users cannot distinguish. Each tone
has a silhouette now. Roughly 24 of 35 indicators also passed no label and
rendered as decorative, so most status in the client was absent from
assistive tech.

- **Body text failed WCAG AA.** `--v2-text-tertiary` measured 3.24:1 on the
shell base and 2.66:1 on raised surfaces. Now 5.57 and 4.57. Modals never
trapped or restored focus, six controls were focusable but not
activatable, and `prefers-reduced-motion` was declared but honoured by
nothing.

Changed

- The desktop app uses the same design tokens as the web client, replacing
its own indigo and violet palette. Tokens are copied by a prebuild step
rather than by hand.
- All 42 Dependabot alerts are closed across five manifests. None of the
five HIGH findings was reachable: they are build-only or test-only, and
read as production-facing because `tailwindcss/vite` sits in
`dependencies`.
- The provider catalog staleness guard now warns at 14 days and fails at 42.
Pricing is display-only until an operator sets a cap, since the budget
ships unlimited, so a permanently red build over a usage figure protected
nothing.
- The provider-research workflow reports "not configured" instead of failing
daily for secrets it cannot create.
- Dependabot no longer opens weekly version PRs for the superseded v1
client. Security alerts for it are unaffected.

Coverage
- pytest (feral-core): 7926 collected, 7895 passed, 31 skipped.
- vitest (feral-client-v2): 818 passed across 119 files.

2026.8.9

Fixed

- **Absence was the only way the system could say "gone".** The
WebSocketDisconnect teardown pops `state.daemons` and unregisters from
`hardware_mesh`, so a phone that dropped stopped existing:
`/api/devices/connected` returned an empty list, the
`connected_devices` tool read the emptied registry and answered
"nothing is connected", and topology fell back to "Awaiting node". An
owner who had paired a phone was told he had never owned one, and the
last thing he saw was a green pulsing dot.

`api/device_view.py` joins live daemons against `node_subdevices`, the
only store that outlives a socket. `devices[]` is unchanged; `offline[]`
and `heartbeat_window_s` are new. The four surfaces that used to
disagree (UI, API, the tool, the prompt block) now derate on one clock.

That clock is 30s, taken from the protocol rather than invented: HUP
keepalive is `heartbeat_ms` (default 10000) with stale at 3x, which is
already `LIVENESS_WINDOWS["ble"]`. A test asserts the constant against
`NodeAckPayload.heartbeat_ms`.

- **`DeviceTopology.jsx` hardcoded a live dot.** Line 161 rendered
`tone="live" pulse` unconditionally while line 181, twenty lines below,
read the real flag.

- **The same glasses appeared six times.** `node_subdevices` is keyed
`(node_id, capability)` and the iOS SDK mints an install-scoped
`feral-iphone-<nonce>`, so six installs left seven rows. Grouping is
presentation-level and deletes nothing: a live database renders 1 phone
and 2 peripherals, the glasses carrying `observations=6`,
`also_seen_via=5`. A stable node id needs an iOS change and is not made
here, so device identity is not silently rewritten.

- **43 of 61 "paired devices" were pairing codes, not devices.**
`/pair/url` and `/pair/qr` stamped `kind="browser"` when the token was
issued, so opening the pair screen recorded a device; `mark_claimed`
then discarded the claimant's identity, and `Pair.jsx` sent
`browser_node_v2`, a transport name an iPhone also sends. Tokens mint as
`pending`, the claim threads `platform` and `node_id`, and the kind is
resolved server-side from what actually claimed it. No rows deleted.

- **Glasses frames were written and read by nothing.** The reader probed
`getattr(glasses_buffer, "get_glasses_buffer", lambda: None)`, a
function the module never defined, so it fell through on every turn.
Measured on a real brain: a frame landed with
`device_ids_with_frames() == ['w610-PROBE']` and the next voice turn
attached no image. Every frame a pair of glasses ever sent was
unreachable.

- **One bad provider adapter could stop the brain from booting.**
`ProviderCatalog` builds every adapter from its own `__init__` and
caught only `ImportError`, so a `ValueError` from a constructor aborted
construction for all sixteen. Reachable through a typo in
`FERAL_CODEX_SANDBOX`, an env var documented in `.env.example`. Fixed at
both ends: the value falls back to `read-only` instead of raising, and
a failing constructor now costs one provider.

Added

- **Codex provider (PR 206, Noah Zerkin).** Talks to
`codex app-server --stdio` over JSON-RPC and uses the signed-in ChatGPT
account. FERAL stores no credentials for it.

`danger-full-access` requires a second opt-in,
`FERAL_CODEX_ALLOW_DANGEROUS_SANDBOX=1`: Codex runs with
`approvalPolicy: "never"`, so that mode executes commands that never
reach `security/dangerous_tools.py`, and one env var reachable by
copying a `.env` was too little friction. The subprocess no longer
inherits `os.environ` wholesale; Codex authenticates itself and needs
none of FERAL's keys.

- **Inactive skills stay visible in the prompt (PR 207, Noah Zerkin).**
They keep name, id and description, gaining "Registered, but not
callable this turn", instead of vanishing and reading as uninstalled.

Changed

- `/api/devices/connected` gains `offline[]` and `heartbeat_window_s`.
Existing `devices[]` consumers are unaffected.
- Devices report `brain_can_initiate: false`. The brain cannot start a
reconnection: there are no push tokens and pairing is phone-initiated.
Reported rather than papered over with a button that cannot work.

Coverage
- pytest (feral-core): 7868 collected, 7837 passed, 31 skipped.
- vitest (feral-client-v2): 614 passed across 98 files.

2026.8.8

Fixed

- **A failed embedding-model load was retried on every call.** Where the
package imports but the model is not cached and the hub is unreachable,
constructing a local backend blocks for the full download timeout.
Whether that can succeed is a property of the process, not of one
provider, but the failure was cached per instance for fastembed and not
at all for sentence-transformers, so every provider re-paid it and on
that path every single embed call did. Memoized process-wide: measured
over 20 providers and 3 embeds each, 40.34s becomes 1.01s.

This is also what made CI red rather than slow. In one matrix run, 50
stalls of about 39 seconds accounted for 33 of the job's 45 minutes
while the other 5,788 tests took 4.1 minutes between them, so the job
was cancelled with no named failure. CI had failed 72 of its last 100
runs and is now green.

- **Vision stopped recording on 2026-07-30 and nothing said so.** It was
never disabled: the screen loop ran for nine days, screenshots
succeeded, and every observation was discarded. Prompts demand "Return
ONLY valid JSON" and the newly configured local model answers with a
perfectly good English caption instead, so the parser returned None and
a correct description of the screen was treated as no observation, at
debug level. Prose is now kept as the description, and a loop producing
nothing warns instead of reporting healthy.

- **Relay tests had never once passed in CI.** websockets 14.0 changed
what `serve` hands the handler, and the pinned 15.0.1 has no `.path` at
all. Local 13.1 resolves to the legacy implementation, so these passed
on a developer machine and only there.

- **soak-nightly was arithmetic, not slowness**: two 60-minute tests run
sequentially against a 90-minute timeout. Cancelled 99 nights out of
100. Now one test per matrix job, with the full hour of soak preserved.

- **A helper that reports whether the running code is a checkout or an
installed copy said "editable" while running a copy.** It tested the
path after rewriting it to the git root, and since site-packages sits
under the home directory, the git walk answered with an unrelated
repository rooted there and reported its commit as the running version.

Added

- **Skill manifest trigger conditions are finally read.** Manifests have
always declared `TriggerDefinition(condition, ...)` and nothing in the
tree ever parsed those strings, which is why triggered routines
degenerated into unconditional one-minute polls. A hand-written
tokenizer and recursive-descent parser now evaluates them, with no
eval, exec or literal_eval anywhere on the path: injection does not
tokenize into anything the grammar accepts. Missing fields evaluate to
unknown rather than false, so an absent sensor cannot satisfy a
"less than" test. A satisfied condition notifies and does not actuate.

- **Browser sessions can be recorded to video.** CDP screencast with
per-frame timestamps, so playback runs at real speed. A missing ffmpeg
keeps the frames and says so rather than losing the recording.
Redaction happens before capture, since masking afterwards leaves
unmasked pixels on disk in the meantime.

- **The browser remembers what each site taught it.** Notes scoped host,
then registrable domain, then global, captured when an interaction
fails in a way that is diagnostic of a site rather than of the
environment, and recalled on the next visit. Nothing stored is
executable. Seeded from browser-use/browser-harness's interaction
guides under MIT, with attribution recorded in THIRD_PARTY_NOTICES.md.

2026.8.7

One theme runs through this release. Across memory, routines, boot and
the HTTP surface, the system reported health it had not earned, and each
report was believed because nothing contradicted it.

Security

- **The `cron` surface had no deny list.** `SURFACE_DENY_LISTS` covered
every other surface, and `is_tool_allowed` returns True for a surface
it cannot find, so the one surface that runs with no human present was
the one with no restrictions. `coding_tools__bash`,
`desktop_control__shell_command` and `code_interpreter__run_python`
were all permitted to a scheduled routine. An absent key is worse than
a wrong entry: it fails open, emits nothing, and leaves the policy file
reading as complete.

The list alone would have been decorative. A DENY was overridable by
`payload["auto_confirm"]`, which is set by the same routine payload
that names the tool, so a routine could grant itself the waiver.
Surface denies are now non-overridable alongside physical-safety
denies.

Fixed

- **Semantic search could not say "nothing matches that."** The vector
leg rejected results below a raw cosine of 0.25, and on a real
11,996-chunk store every chunk cleared that floor for every query,
including `asdfgh zxcvbn qwerty`. The floor was not too low, it was
measuring the wrong thing: embeddings occupy a narrow cone, so raw
cosine is dominated by a direction every document shares. Subtracting
the corpus mean separates the populations, and nonsense now returns
zero results instead of a confident wrong memory.

- **Search returned the same memory five times.** No diversity step
existed, so a corpus containing many near-identical episodes could fill
every slot with one sentence.

- **The vector backend label was a default, not a probe.** Status
surfaces reported `sqlite_vec` whenever they could not tell, while
`/internal/memory/stats` already knew the extension had not loaded and
queries were being served by a numpy scan.

- **A routine that did nothing recorded success**, with a message
blaming a missing configuration that was usually present. One routine
collected 4,765 such successes without ever acting.

- **Triggered routines fired unconditionally.** They were created with a
one-minute poll and a condition nothing has ever read, so the action
ran regardless. One was a messaging send gated on a stress reading,
inert only because its skill was not installed.

- **`/api/ambient/briefing` had never returned data in any field**, and
every failure was logged at debug, so an empty briefing looked like a
quiet morning. `wind_down` called a method that existed nowhere, so the
evening recap reported an empty day however much was finished.

- **Twenty-one route handlers turned a lost answer into an empty one.**
`/api/jobs` was the worst: five aggregators each returned `[]` on
failure, so a dead source and an idle system were byte-identical.

- **`POST /api/push/send` had never once succeeded**: it awaited a
synchronous function, and the route's bare except swallowed the
TypeError. A device registering as `ios` also had its APNs token
routed to Firebase.

- **Boot graded construction, not function.** `OK` meant the constructor
did not raise. `LLMProvider` reported OK while every call it made
returned 401.

Added

- **Proactive alerts can reach a human who is not at a screen.** Delivery
went to open browser sessions and nowhere else, so with no tab open the
message was destroyed. Escalation is gated at IMPORTANT and above,
chosen from the observed distribution: of 2,441 real alerts, 2,384 were
break reminders and 32 concerned the user's body.

- **Routines that have stopped working are now noticed.** Nothing had
ever read `routine_runs` back. One routine failed 4,824 times out of
4,824 over six weeks while still enabled and firing every minute.

- **Every boot states which copy of the code it is running.** A full day
of committed fixes appeared to do nothing because the process imported
an installed copy while the edits lived in the working tree.

2026.8.6

Fixed

- **Tailscale Funnel failures are no longer silent.** A user with
Tailscale installed, running and logged in was told only
`tailscale funnel --bg 9090 timed out after 20.0s`. Two defects in
`integrations/tailscale.py::_run` produced that, and both hide
information rather than lose functionality, which is why reading the
code found them and a week of guessing did not.

`subprocess.run` inherited stdin. `tailscale funnel` prompts for
confirmation when Funnel is not yet enabled on the tailnet: it prints
an enable URL and waits. Called from a daemon or an API request there
is no one to answer, so it blocked until the timeout. stdin is now
closed, so the CLI reads EOF and exits with its message.

`subprocess.TimeoutExpired` carries whatever the process wrote before
the timeout. The handler discarded both streams, so the enable URL was
read off the pipe and thrown away. Partial output is now surfaced and
classified first, so a timeout carrying a known message raises the
same typed error a non-zero exit would.

- **Tailscale mode keeps its bind host.** A change in 2026.8.5 derived a
loopback bind for `remote` mode, and the boot repair would then have
narrowed an existing `0.0.0.0` on upgrade. Funnel proxies to localhost
so loopback works, but a brain reached directly on its tailnet address
needs the interface. Caught before it shipped to anyone in that state.

Added

- **`feral doctor` probes Tailscale**: binary, daemon, account, funnel,
and coherence between the stored remote URL and the live one. Severity
follows declared intent, so a missing Tailscale is informational when
pairing over WiFi and a failure when the mode is `remote`. Every probe
has a 2.5s budget, and a timeout reports as "installed but wedged"
rather than "not installed".
- The pair QR carries the candidate address list, so a phone that scans
it learns every address the brain answers on rather than one.

Not yet operable

Relay groundwork continues to ship as inert code: a tunnel broker, an
SNI reader, certificate issuance, and a brain-side relay client. No
WebSocket transport is wired to any of it and no call has ever been made
to Let's Encrypt. Remote access still means Tailscale.

Page 1 of 16

© 2026 Safety CLI Cybersecurity Inc. All Rights Reserved.