Releases: xunholy/promptzero
Release list
v0.799.0
v0.799.0 — wigle_upload: authenticated wardrive-CSV upload to wigle.net
Added (capability — roadmap P2-26, upload half)
-
wigle_upload POSTs a WigleWifi-1.4 wardrive CSV (the output of
wigle_wardrive_export) to the WiGLE v2 file-upload API. This is PromptZero's
first outward-network-egress tool, so it is deliberately conservative and
double-gated:- Gate 1 (config opt-in): wigle.upload_enabled defaults false; the tool
refuses until the operator arms it — the egress capability is off out of
the box. - Gate 2 (dispatch): classified risk.High, so every call goes through the
confirmation + audit gates (over MCP it also requires
PROMPTZERO_MCP_ALLOW_HIGH).
Credentials (WiGLE API name + token) come from wigle.api_name/api_token or the
WIGLE_API_NAME / WIGLE_API_TOKEN env vars — never from tool arguments, and
never echoed into results or error messages.
- Gate 1 (config opt-in): wigle.upload_enabled defaults false; the tool
-
Client (internal/wigle.Client): multipart/form-data POST, HTTP Basic auth
(API name = user, token = password — WiGLE's real auth; not OAuth), distinct
401 (auth) and 429 (rate-limit) reporting, success:false surfaced as an error,
request (32 MiB) and response (1 MiB) bounded, donate opt-in (off by default).
The endpoint is overridable so the whole test suite runs against an httptest
mock — no live egress occurs during development or CI.
Out of scope (P2-26 remainder): GPS auto-stamping (flipper_gps_fix + Marauder
scan stamping) is hardware-dependent and still open.
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - 13 offline tests (7 client + 6 handler): success/auth/multipart/donate, 401,
429, success:false, malformed body, input guards; disabled-by-default and
missing-credential refusals, and a token-not-leaked assertion on the result. - Gated-tool guardrails updated: register(High), registry-size pin 702->703,
gatedTools golden set; classify==spec and risk-coverage invariants hold.
v0.798.0
v0.798.0 — web-share: carry the token in the URL fragment, not a query string
Fixed (security + functional — --web-share)
-
--web-share printed the bearer token in a query string
(http://host:port/?token=). Two problems:-
Secret leak. A ?token= URL lands in the server access log, any
reverse-proxy log, browser history, and the Referer header of any outbound
request the page makes — exactly the leakage the rest of the codebase
avoids (/ws refuses query-string tokens; the client scrubs the token from
history after reading it). --web-share reintroduced it. -
The shared URL didn't work. The web client's authBootstrap reads the token
only from the URL FRAGMENT (#token=), never the query string, so a teammate
opening the ?token= link was dropped at a manual token prompt.
Both fixed by carrying the token in the fragment (#token=): a fragment is never
sent to the server (no log/proxy/Referer leak), is scrubbed from history by the
existing client code, and is the form the client actually parses. Extracted
into webShareURL so the invariant is unit-tested. -
Found via the internal audit sweep of the web session/token lifecycle. The rest
verified sound: constant-time token compare (subtle.ConstantTimeCompare),
operator-supplied token, crypto/rand correlation ids, gated tool dispatch via
the agent RunTool, no CSRF-via-GET, no cookies.
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestWebShareURL_UsesFragmentNotQuery: asserts #token= and never ?token=.
v0.797.0
v0.797.0 — audit: VerifyChain reports a poisoned success value as a chain break
Fixed (safety — audit tamper-evidence robustness)
-
VerifyChain scanned the success column into a bare Go bool. Legitimate rows
always store 0/1 (schema DEFAULT 1), but success and entry_hash are
independent columns an attacker with direct SQLite write can set separately.
Setting success to NULL or an out-of-range integer (e.g. 2) makes rows.Scan
fail ("couldn't convert 2 into type bool"), so VerifyChain returns an error
and ABORTS the entire walk instead of the designed "chain broken at row N"
verdict. A single UPDATE ... SET success=NULL turns the forensic integrity
check into a self-inflicted DoS.This is fail-loud, not fail-open — it can never read a tampered log as
Valid=true — but it degrades tamper-evidence: an operator gets an opaque type
error instead of a pinpointed break, and the verifier can't report which rows
are broken. Fix: scan success as sql.NullInt64 (matching the sibling
duration_ms) and feed success.Int64 == 1 into chainHash. Behaviour-preserving
for legitimate 0/1 rows; any poisoned value now flows into the hash recompute,
fails the comparison, and is correctly reported as a chain break
(Valid=false, FirstBrokenID=id).
Found via the internal audit sweep of the query DSL + hash chain. The rest
verified sound: chain pre-image injectivity (length-prefixed fields), verify
recomputes rather than trusting the stored hash, reorder/deletion/truncation +
CheckpointAnchor detection, fully-parameterized WHERE builder, capped LIMIT,
writeMu + flock single-writer, CSV formula-injection guard.
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestVerifyChain_PoisonedSuccessReportsBreak (success=2 and success=NULL):
reports a break at the poisoned row without erroring; proven to fail pre-fix
(both aborted with a bool scan error).
Follow-up (LOW, banked): Query/QueryFiltered share the bare-bool scan, where a
NULL-success row is silently dropped from results; barely reachable, tracked
separately.
v0.796.0
v0.796.0 — rules: don't consume cooldown when a fire dispatched nothing
Fixed (correctness — reactive rules engine)
-
Engine.Handle bumped lastFire and fires for a matched rule BEFORE dispatching
its actions. When the rule's only action was an ActionTool dropped because the
global in-flight cap (maxToolActions=8) was saturated, nothing actually ran —
yet the rule was recorded as fired and its cooldown clock started. The real
damage is the cooldown: the NEXT genuinely-matching event inside the window is
then wrongly suppressed, so a saturation blip silently swallows a real
trigger. /rules list also over-reported Fires/LastFire.Handle now records the fire optimistically under the lock (so a concurrent
Handle for the same rule is still cooldown-suppressed) and rolls it back when
fire() reports nothing dispatched: fires-- and lastFire restored to its prior
value (or deleted). The restore is guarded by an Equal(bump) check so a
concurrent successful fire that legitimately advanced lastFire is never
clobbered. fire() now returns whether at least one action dispatched (a
webhook/log ran, or a tool slot was reserved); a missing dispatcher, unknown
kind, or saturation-drop counts as not-dispatched. -
Negative Rule.Cooldown is clamped to 0 at Register (with a warn), so an
operator sign typo can't slip past theCooldown > 0suppression guard and
silently disable rate-limiting.
Found via the internal audit sweep of the automation engines (v0.795.0 fixed the
campaign fail-open from the same sweep; this clears the two banked rules items).
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestEngine_SaturationDropDoesNotConsumeCooldown (-race): saturate the cap,
fire a tool-only rule, assert Fires==0 / LastFire unset; free the cap and
assert the same event now fires. Proven to fail pre-fix (the drop consumed the
cooldown and suppressed the next event). TestEngine_NegativeCooldownClampedToZero.
v0.795.0
v0.795.0 — campaign: reject an inert when-gate (fail-open fix)
Fixed (safety — campaign conditional gate fails open)
-
A campaign step's
whenclause is evaluated against its predecessor's output,
and that evaluation lives entirely inside the Runner'sif step.DependsOn != ""
block. A step authored with awhen:but nodepends_on:never reaches the
when check — the Runner runs its tool UNCONDITIONALLY.whenis exactly how an
operator makes a destructive tool conditional (e.g.when: contains "AUTHORIZED"), so a missing depends_on turns an intended gate into a silently-
inert one: a fail-open.ParseYAML now rejects
When != "" && DependsOn == ""at load, matching the
package's validate-at-parse philosophy (duplicate ids, unresolved / self /
forward depends_on, cycles, and malformed/negative timeouts already fail at
load). Awhenwith no predecessor has no output to evaluate against, so it is
meaningless as well as unsafe. ParseYAML is the sole production construction
path (the web campaign API, ParseFile, and eval all route through it — no
struct-literal bypass), so the parse-time rejection fully closes the fail-open.
Found via the internal audit sweep of the automation engines (rules / campaign /
workflows). The rest verified sound: tool dispatch routes through RunTool's
audit+confirm gates; rules template-into-params is operator-scoped and non-
recursive; cooldown is race-free; inFlight is leak-safe; the campaign dependency
graph rejects dup/unresolved/self/forward/cyclic/malformed-timeout at load.
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestParseYAML_RejectsWhenWithoutDependsOn — proven to fail pre-fix (the
campaign parsed clean and the step would have run unconditionally).
v0.794.0
v0.794.0 — risk: centralize dispatch escalation + guard the classify/spec invariant
Changed (architecture / safety hardening — behavior-preserving)
The dispatch safety gates are re-implemented per surface (the agent Run loop,
agent.RunTool, the MCP server), each resolving a tool's effective risk
independently. That copy-paste is how they have drifted before — run_payload
escalation was once missing from RunTool, and the MCP audit rail was missing
until v0.792.0. This release removes two pieces of that drift surface without
changing any gate, threshold, or level.
-
Centralized run_payload escalation. All three sites duplicated "if the tool is
run_payload, resolve the target path and take max(base, derived)" so a payload
dispatching to a Critical op (a .sub transmit, badusb script, evil-portal
launch) is gated at its real level. The rule now lives in one place,
risk.EscalateForPath(tool, base, path); each surface computes its base as
before and routes through the helper, so they can no longer drift on whether or
how they escalate. -
Guarded the classify==spec invariant. The Run loop gates on risk.Classify
alone while RunTool and MCP gate on Spec.Risk (max Classify). They agree for
all 702 registered tools today, and that agreement is exactly what makes the
Run loop's Classify-only resolution safe — but nothing enforced it.
TestRiskClassificationMatchesSpec now fails CI if a tool's Classify value (the
hand-maintained risk.toolLevels map) disagrees with its registry Spec.Risk, so
a tool registered Critical but mapped lower can't be silently under-gated on
the Run-loop path. Complements TestRiskCoverage (which only checks a
classification exists, not that its value matches).
Found while hardening the dispatch surfaces after the v0.792.0 MCP audit fix.
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestEscalateForPath: 10 table cases (each extension→level, never-lowers, empty
path, non-dispatcher tool unchanged). TestRiskClassificationMatchesSpec proven
(via an injected runtime Classify override) to catch a divergence. The existing
payload_risk / runtool / MCP consent suites still pass — proof the three sites
are behavior-identical.
v0.793.0
v0.793.0 — targetmem: cap retained rows to bound table growth
Changed (robustness — bounded persistent storage)
-
The target-memory store grew without bound across sessions: MaxRecent bounds
only the per-call read, and while Remember upserts on the (identifier, kind)
primary key — so re-observing the same badge/SSID dedupes — a flood of
DISTINCT attacker-presented identifiers (a scan loop against many spoofed
UIDs/SSIDs, or a long-lived install) would grow ~/.promptzero/targetmem.db
without limit.Adds a retention cap mirroring snapshot.Rotate: after each write Remember
prunes to the MaxTargets (50000) most-recently-seen rows, deleting the oldest
by last_seen. A cheap COUNT(*) guards the DELETE so the common already-under-cap
case does no work; last_seen is stored RFC3339 so the ORDER BY is chronological
and uses idx_targets_last_seen. 50000 small rows is generous for any realistic
pentest history while bounding a hostile flood to a few MB. The cap is a Store
field defaulting to MaxTargets, overridable for tests.
Found via the internal audit sweep (the last remaining LOW; the DB is per-user
0o600 and dedupes re-observations, so this is defense-in-depth against a
distinct-identifier flood, not a live exploit).
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestRemember_PrunesToMaxTargets inserts past a low cap and asserts the oldest
rows are evicted while the newest survive; proven to fail pre-fix (5 rows
retained instead of 3).
v0.792.0
v0.792.0 — mcp: enforce audit fail-closed before running a tool
Fixed (safety — audit fail-closed on the MCP surface)
-
The MCP dispatch path gated tools on consentDecision (the env-driven
PROMPTZERO_MCP_ALLOW_HIGH / ALLOW_CRITICAL opt-in) but never called
audit.RequireOpen — the rail the REPL and --web dispatch both apply. Audit
logging is wired best-effort: if audit.Open fails (a locked SQLite WAL from
another promptzero process, an unwritable ~/.promptzero, an unset $HOME), the
MCP server continues with a nil audit log and the RecordCtx calls silently
no-op. So with the operator opted in via env AND the audit log failed to open,
a High/Critical MCP tool (NFC / sub-GHz transmit, run_payload deploy) ran with
NO audit record — a fail-open relative to the documented "every destructive
action is audited" invariant, and a silent divergence from the REPL/web
surfaces where the identical scenario is refused.Adds audit.RequireOpen(s.audit, effectiveLevel) before the consent gate, so the
MCP surface fails closed identically to the agent path: risk≥High is refused
when no audit log is wired; Low/Medium tools are unaffected. The run_payload
Critical escalation is already applied to effectiveLevel before this gate.
Found via the internal audit sweep (bootstrap + web-route surfaces, otherwise
clean).
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full (full
suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five release
cross-compile targets build green. - TestServer_CallTool_AuditFailClosedWhenNoAuditLog: a High tool with
ALLOW_HIGH=1 but no audit log is refused with "audit log not initialized" and
the handler never runs; -race clean, proven to fail pre-fix. The MCP test
harness now wires an audit log by default (production does); the five consent
matrix tests still pass.
v0.791.0
v0.791.0 — sanitize unterminated C1 sequences (quarantine) + /debug device strings
Fixed (safety — terminal-control / prompt-injection sanitization)
-
quarantine: strip UNTERMINATED C1 control strings. The prompt-injection
sanitizer's ansiC1RE matched only OSC/DCS/SOS/PM/APC sequences carrying their
BEL/ST terminator, so an attacker could omit the terminator (e.g. a crafted
SSID/NFC field surfaced via a quarantined tool result) — ansiC1RE wouldn't
match, otherControlsRE stripped only the leading ESC, and the body survived as
readable text ("]0;PWNED ..."), defeating the "strips the full sequence
including the payload" guarantee. New ansiC1UntermRE strips the introducer plus
its rest-of-line payload (bounded to the current line so one unterminated
sequence can't blank multi-line tool output), applied after ansiC1RE. -
obs /debug: sanitize device-reported strings before rendering. kv() rendered
the Flipper firmware-model string (and port path) straight to the operator's
terminal; a compromised Flipper could report a model with ANSI escapes or
newlines to inject terminal-control sequences or break the /debug box. kv now
runs every value through quarantine.SanitizeControlChars and flattens the
whitespace control chars the sanitizer preserves for tabular output.
Found via the internal audit sweep (targetmem + obs surfaces, otherwise clean —
SQLite-backed targetmem has no path-traversal surface, parameterized queries,
quarantined recall output).
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full
(full suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five
release cross-compile targets build green. - TestSanitizeControlChars_StripsUnterminatedC1 (quarantine) +
TestRender_SanitizesDeviceReportedModel (obs), both proven to fail pre-fix;
agent-package forwarder test updated to the stricter expectation.
v0.790.0
v0.790.0 — watch: scope rule personas; no dispatch after shutdown
Fixed (correctness — file-watch automation)
-
The auto-dispatch path is sound (a fired rule runs its prompt through the
gated agent loop ai.Run, so read-only / confirm / audit / risk all apply);
these are robustness / least-privilege fixes around it. -
Watch-rule persona persistence (least privilege): when a watch rule named a
persona the REPL set it via ai.SetPersona and never restored it, so the
rule's (possibly broader) persona and its tool scope silently persisted into
the operator's subsequent interactive turns. applyWatchPersona now applies
the rule's persona only for its turn and restores the prior persona after
dispatch. The dispatcher waits for the REPL to be idle first, so no
concurrent user turn observes the swap. -
Debounced dispatch firing after shutdown: flushTimers (on ctx cancel)
Stop()s pending timers, but a timer that had already fired and was blocked
acquiring mu would still dispatch once flushTimers released the lock — a
handler running after Run returned. A new Watcher.stopped flag, set under mu
by flushTimers and checked by the fired goroutine after it re-acquires mu,
makes it bail. Benign today (the REPL event consumer has already exited) but
closed.
Found via the internal audit sweep.
Verified
- task ci green: golangci-lint 0 issues, go vet, build, task test:full
(full suite, -race) 0 fail, govulncheck clean. task eval 17/17. All five
release cross-compile targets build green. - TestApplyWatchPersona_ScopesAndRestores; TestWatcher_StoppedSuppressesFiredTimer
(deterministic, -race clean, proven to FAIL pre-fix).