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
9 changes: 9 additions & 0 deletions anthill/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6146,6 +6146,7 @@ def personalize_get(request: Request, user: dict = Depends(_require_user)):
org = _require_org(db, user)
me = db.query(User).filter(User.id == int(user["sub"])).first()
from .. import planes
from ..hybrid.scrub import presidio_available
from ..training.model_select import is_local_training, is_solo_account

cfg = _cfg(db, org)
Expand Down Expand Up @@ -6284,6 +6285,7 @@ def personalize_get(request: Request, user: dict = Depends(_require_user)):
"web_access_on": bool(getattr(me, "web_access_on", False)) if me else False,
"thinking_on": bool(getattr(me, "thinking_on", False)) if me else False,
"scrub_on": bool(getattr(cfg, "cloud_scrub_pii", True)) if cfg else True,
"presidio_available": presidio_available(),
"wiki_count": wiki_count,
"model_storage_gb": model_storage_gb,
"integrations_connected": integrations_connected,
Expand Down Expand Up @@ -9003,6 +9005,9 @@ async def settings_post(
user_id=user["sub"],
)
audit.log(db, "settings.updated", "", org_id=org.id, user_id=user["sub"])
from ..hybrid import privacy_pack as pack
from ..hybrid.scrub import presidio_available, scrub_coverage

return templates.TemplateResponse(
request,
"settings.html",
Expand All @@ -9012,6 +9017,10 @@ async def settings_post(
"org": org,
"cfg": cfg,
"saved": True,
"scrub_coverage": scrub_coverage(),
"presidio_available": presidio_available(),
"privacy_pack_can_install": pack.can_install(),
"privacy_installing": _privacy_pack_installing,
},
)

Expand Down
2 changes: 1 addition & 1 deletion anthill/web/templates/audit.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ <h3 style="margin-bottom:12px">Recent events (last 200)</h3>
{% for e in events %}
<tr>
<td class="text-sm text-muted" style="white-space:nowrap">
{{ e.created_at.strftime('%m-%d %H:%M:%S') if e.created_at else '-' }}
{{ e.created_at.strftime('%m-%d %H:%M:%S UTC') if e.created_at else '-' }}
</td>
<td>
<span class="badge {% if 'fail' in e.event or 'reject' in e.event %}badge-danger
Expand Down
3 changes: 2 additions & 1 deletion anthill/web/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ <h3>Quick actions</h3>
</div>
</div>
{# A solo account has no organisation - showing "Organization / Name / role" there is nonsense, so
solo gets a plain workspace summary instead; only a real org shows org identity + audit log. #}
solo gets a plain workspace summary instead; both link to the audit log and how-it-works guide. #}
{% if solo %}
<div class="card">
<h3>Your workspace</h3>
Expand All @@ -309,6 +309,7 @@ <h3>Your workspace</h3>
{% if council_members|length > 1 %}<b>Council:</b> {{ council_members|length }} models{% else %}<b>Model:</b> {{ council_members[0] if council_members else 'Not chosen yet' }}{% endif %}
</div>
<div style="margin-top:16px;display:flex;gap:16px;flex-wrap:wrap">
<a href="/audit" class="text-sm"><i data-lucide="scroll-text"></i> Audit log →</a>
<a href="/docs/how-it-works" class="text-sm"><i data-lucide="book-open"></i> How Anthill works →</a>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions anthill/web/templates/personalize.html
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@
<label class="tog"><input type="checkbox" name="memory_on" {% if memory_on %}checked{% endif %}><span class="track"></span><span class="knob"></span></label>
</div>
<div class="setrow">
<div class="setrow-main"><span class="setrow-name">Scrub personal details before training</span><span class="setrow-d">Removes names, emails and IDs first.</span></div>
<div class="setrow-main"><span class="setrow-name">Scrub personal details before training</span><span class="setrow-d">{% if presidio_available %}Removes names, locations, emails, phone numbers and IDs first.{% else %}Removes emails, phone numbers and IDs first. Names and places need the privacy pack{% if user and user.role == 'admin' %} (<a href="/settings">Install</a>){% endif %}.{% endif %}</span></div>
<label class="tog"><input type="checkbox" name="scrub_pii" {% if scrub_on %}checked{% endif %}><span class="track"></span><span class="knob"></span></label>
</div>
<div class="setrow">
Expand All @@ -377,7 +377,7 @@
</div>

{% if user and user.role == 'admin' %}
<p class="text-sm text-muted" style="margin:4px 0 0"><a href="/settings">Advanced privacy settings</a> - PII scrubbing coverage and a one-click privacy pack.</p>
<p class="text-sm text-muted" style="margin:4px 0 0"><a href="/settings">Advanced privacy settings</a> - PII scrubbing coverage and a one-click privacy pack. &middot; <a href="/audit">Audit log</a> - every model call and upload.</p>
{% endif %}
</div>

Expand Down
4 changes: 2 additions & 2 deletions anthill/web/templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ <h3 style="margin-bottom:14px">Local inference <span class="text-sm text-muted">
<div class="card" style="margin-top:16px">
<h3 style="margin-bottom:12px">Cloud privacy <span class="text-sm text-muted">(PII scrubbing)</span><button type="button" class="help" aria-label="About PII scrubbing">?<span class="help-pop">Before any text goes to a cloud model, Anthill redacts personal information. The privacy pack adds redaction of free-text names and locations.</span></button></h3>
{% if presidio_available %}
<p class="text-sm"><span class="badge badge-active">Full</span> Covers {{ scrub_coverage }}, including names and locations.</p>
<p class="text-sm"><span class="badge badge-active">Full</span> Covers {{ scrub_coverage }}.</p>
{% else %}
<p class="text-sm" style="margin:0 0 12px"><span class="badge badge-pending">Partial</span> Covers {{ scrub_coverage }}. Names and locations are not redacted yet.</p>
<p class="text-sm" style="margin:0 0 12px"><span class="badge badge-pending">Partial</span> Covers {{ scrub_coverage }}.</p>
{% if privacy_pack_can_install %}
<form method="post" action="/privacy-pack/install" style="display:inline">
<button id="privacy-install-btn" type="submit" class="btn btn-sm btn-primary"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/155.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Settings -> Privacy now states actual PII scrub coverage (#155).** The "Scrub personal details before training" toggle in `/personalize` branches on `presidio_available()` instead of promising name removal when the Presidio privacy pack is not installed, and `/settings` no longer repeats the "not redacted" caveat twice in one line.
1 change: 1 addition & 0 deletions changelog.d/156.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Solo users can reach the audit log from the dashboard and Privacy settings, with UTC-labeled timestamps (#156).** Added direct `/audit` links to the Solo dashboard's "Your workspace" card and the Settings -> Privacy tab, and labeled recent event timestamps on `/audit` with `UTC`.
22 changes: 14 additions & 8 deletions docs/specs/privacy-pack-and-scrub-transparency.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Spec: Surface the PII-scrub coverage + a one-click privacy pack

Status: implemented (CLI + backend). Lane: `pillar:privacy`. Issue: #540.
Status: implemented (CLI + backend + web). Lane: `pillar:privacy`. Issues: #540, #155, #156.

## Problem

Expand Down Expand Up @@ -29,13 +29,19 @@ structured-only. A transparency/posture gap, not a code defect (the scrub works

## Web surface

The Settings page shows a "Cloud privacy" card with `scrub_coverage()` and, when name/location redaction
isn't installed and `privacy_pack.can_install()` is True, a one-click **Install privacy pack** button.
It posts to `POST /privacy-pack/install` (admin, audit-logged), which runs `privacy_pack.install()` in a
background thread; the page polls `GET /privacy-pack/status` and updates when it's ready. A packaged app
that can't install shows the limitation instead of a button. (The web cloud-escalation consent gate is
still not wired - cloud escalation is fail-closed on the web path - so the CLI `--cloud` prompt remains
the live per-send surface; the card is where the web admin sees and closes the gap.)
The Settings page (`/settings`) shows a "Cloud privacy" card with `scrub_coverage()` (without repeating
the names/locations caveat a second time) and, when name/location redaction isn't installed and
`privacy_pack.can_install()` is True, a one-click **Install privacy pack** button. It posts to
`POST /privacy-pack/install` (admin, audit-logged), which runs `privacy_pack.install()` in a background
thread; the page polls `GET /privacy-pack/status` and updates when it's ready. A packaged app that can't
install shows the limitation instead of a button.

On `/personalize` (Settings -> Privacy), the "Scrub personal details before training" toggle description
also branches on `presidio_available()` (#155): when the privacy pack is absent it states that emails,
phone numbers and IDs are removed while names and places need the privacy pack (linking admins to
`/settings` to install it), rather than promising that names are removed on a base install. The Privacy
tab and the Solo dashboard's "Your workspace" card both link directly to `/audit`, and `/audit` labels
recent event timestamps with `UTC` (#156).

## Out of scope here (follow-up)

Expand Down
35 changes: 34 additions & 1 deletion tests/test_privacy_pack_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,43 @@ def test_settings_shows_the_gap_and_install_button(tmp_path, monkeypatch):
monkeypatch.setattr(_pack, "can_install", lambda: True)
c, _, _ = _admin(tmp_path, monkeypatch)
body = c.get("/settings").text
assert "not redacted yet" in body # the gap is surfaced
assert "NOT redacted without the privacy pack" in body # the gap is surfaced
assert "not redacted yet" not in body # no duplicated trailing sentence (#155)
assert 'action="/privacy-pack/install"' in body # ...with a one-click install


def test_personalize_privacy_scrub_copy_matches_presidio_state(tmp_path, monkeypatch):
c, _, _ = _admin(tmp_path, monkeypatch)
monkeypatch.setattr(_scrub, "presidio_available", lambda: False)
body_absent = c.get("/personalize").text
assert "Removes emails, phone numbers and IDs first." in body_absent
assert "Names and places need the privacy pack" in body_absent
assert "Removes names, emails and IDs first." not in body_absent
assert 'href="/audit"' in body_absent # Solo/admin Privacy tab links to /audit (#156)

monkeypatch.setattr(_scrub, "presidio_available", lambda: True)
body_present = c.get("/personalize").text
assert "Removes names, locations, emails, phone numbers and IDs first." in body_present


def test_solo_dashboard_links_audit_and_audit_labels_utc(tmp_path, monkeypatch):
from anthill.web import audit as audit_mod

c, app_mod, org_id = _admin(tmp_path, monkeypatch)
s = app_mod._SessionFactory()
cfg = s.query(OrgSettings).filter_by(org_id=org_id).first()
cfg.deployment_topology = "solo"
cfg.local_model_chosen = True
audit_mod.log(s, "inference.call", "model=qwen2.5:3b", org_id=org_id, user_id=1)
s.commit()

dash = c.get("/").text
assert "<h3>Your workspace</h3>" in dash
assert 'href="/audit"' in dash
audit_page = c.get("/audit").text
assert " UTC" in audit_page and "inference.call" in audit_page


def test_settings_shows_installed_state(tmp_path, monkeypatch):
monkeypatch.setattr(_scrub, "presidio_available", lambda: True)
c, _, _ = _admin(tmp_path, monkeypatch)
Expand Down
Loading