Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1113,3 +1113,192 @@ documented").
|
||||
`settings-ownership-contract`, `search-routing-contract`), then the read-only
|
||||
system one (`user-accounts-contract`), then the harness ones
|
||||
(`settings-search-contract`), and `settings-pages-contract` last, as before.
|
||||
|
||||
## Phase 12 (Privacy & Security) — append below
|
||||
|
||||
Spec: `2026-08-24-privacy-security-redesign.md`. Privacy stopped being a page
|
||||
that read three device rows and pointed at GNOME for everything else. The portal
|
||||
model went from three hardcoded devices to six tables — camera, microphone,
|
||||
screencast, remote-desktop, background, location — with screencast and
|
||||
remote-desktop revoke-only, because their stored value is a structured GVariant
|
||||
describing a whole session and a switch cannot rebuild one. Traces became
|
||||
native: a new `panama-privacy` empties the recent-files list and the thumbnail
|
||||
cache, and the trash row reuses Storage's existing cleanable rather than growing
|
||||
a second implementation. SSH Keys learned to make a key, with the passphrase
|
||||
typed at ssh-keygen over a pty. The duplicated Screen-lock card was deleted and
|
||||
replaced with a pointer.
|
||||
|
||||
Three agents edited the tree concurrently. Everything below was reconciled
|
||||
against the landed files at the end of the phase rather than against the spec's
|
||||
pinned shapes, and four needles moved during it: `speakers` left the permissions
|
||||
model, the `set` verb became `true|false` rather than `allow|deny`, the
|
||||
Privacy page's confirm state became `confirmingItem` (with `confirmingRevoke`
|
||||
and `confirmingTrace` beside it), and the passphrase turned out to travel
|
||||
through a helper of its own (`type_at_keygen`) rather than inside `generate`.
|
||||
Each is pinned where it actually landed.
|
||||
|
||||
### New contracts (1)
|
||||
|
||||
`quickshell/privacy-traces-contract`. The README count line moves
|
||||
**172 → 173**; `setup/readme-contract` was run and passes ("173 contracts, as
|
||||
documented").
|
||||
|
||||
### Run and passing
|
||||
|
||||
- **`quickshell/permissions-contract` — RUN END TO END, PASS.** Rewritten: the
|
||||
old version set and cleared a camera permission on the REAL portal store
|
||||
under a probe application id and put it back afterwards. It now runs under
|
||||
`env -i` against a recording `busctl` first on `PATH` (asserted to resolve
|
||||
inside the stub directory before anything that writes runs), with
|
||||
`DBUS_SESSION_BUS_ADDRESS` pointed at a socket that does not exist. What it
|
||||
pins:
|
||||
- **No Set path for a structured table, three ways.** By import — the helper
|
||||
is loaded as a module so the table lists are read at their real values
|
||||
rather than pattern-matched, because they are derived from a registry and a
|
||||
contract that understood one spelling of that would be pinning the spelling.
|
||||
By AST — `SetPermission` is named exactly once in the file, inside one
|
||||
function, which consults a named list containing `camera`; that list must
|
||||
not contain `screencast` or `remote-desktop`, and the function must not name
|
||||
them either. And at runtime — `set screencast …` is refused **and** the call
|
||||
log gains no `SetPermission`, which is the assertion that survives a
|
||||
refactor of both of the others.
|
||||
- **Revoke reaches every remembered token.** The fixture store gives one
|
||||
application two screencast sessions; the snapshot has to fold them into ONE
|
||||
row carrying `grants: 2`, and `forget` has to issue two `DeletePermission`
|
||||
calls. Dropping the first token and leaving the row looking unchanged is the
|
||||
failure this catches.
|
||||
- **A path-shaped entry id from the store is dropped, not fed back in.** The
|
||||
stub's `List` answers with `"../../etc/passwd"` among the tokens; it must
|
||||
not appear in any subsequent call.
|
||||
- **Absence is still not failure**: the stub answers `No entry for microphone`
|
||||
on stderr with a non-zero exit, and the snapshot must report an empty
|
||||
microphone table with `error: ""`.
|
||||
- **`speakers` is gone**, pinned as an absence: no portal backend arbitrates
|
||||
speaker access, so the row was a permanent "nothing has asked" for a
|
||||
question nobody asks, and listing it made the page look wider than it is.
|
||||
- **`Permissions.devices` survives** as the camera/microphone compat view, and
|
||||
something still reads it — ApplicationsPage does, and the symptom of losing
|
||||
it there is an empty list rather than an error.
|
||||
- Validated table and application ids, each checked twice: refused *with a
|
||||
reason*, and having reached no call.
|
||||
- **`quickshell/privacy-traces-contract` — RUN END TO END, PASS.** New, and
|
||||
hermetic throughout: `env -i` with `HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CACHE_HOME` inside a scratch tree, and nothing that deletes runs until
|
||||
the helper has reported the fixture's own numbers back (9 × 33333 bytes of
|
||||
thumbnails, two recent entries). What it pins:
|
||||
- **Clearing recents empties the file, never unlinks it**, and what is left
|
||||
parses as XML with `<xbel>` as its root and no bookmarks. A truncation to
|
||||
zero bytes fails: GTK reads a zero-length file as corrupt and stops
|
||||
recording history, which looks identical to success.
|
||||
- **Clearing thumbnails does not follow a link out.** The fixture plants both
|
||||
traps — a symlink to a file that must survive and a symlinked directory that
|
||||
must be unlinked rather than descended — and separately points a whole
|
||||
scratch `~/.cache/thumbnails` at a directory outside the home, which has to
|
||||
be refused in words.
|
||||
- **The seam is not a way around the guard**: `PANAMA_PRIVACY_THUMBNAILS`
|
||||
pointed outside the home is refused exactly as a symlink is, which is what
|
||||
makes the rest of this file a test rather than a way of disabling the check.
|
||||
- **One trash implementation**, said three ways: the page reaches
|
||||
`Disks.clean`/`Disks.cleanables`, the helper's *code* (docstrings excluded by
|
||||
AST, since they are allowed to say why) never mentions the trash, and
|
||||
neither does `Traces.qml`.
|
||||
- **No urgency language in the Traces card**, found by what the card is wired
|
||||
to rather than by its title, and read only from its string literals.
|
||||
- **`quickshell/ssh-keys-contract` — RUN END TO END, PASS.** Its read-only half
|
||||
is unchanged and still runs against the real configuration. The new half runs
|
||||
under `env -i` with `HOME` in a scratch tree and a recording `ssh-keygen`
|
||||
first on `PATH`, which logs its own `/proc/$$/cmdline` and `/proc/$$/environ`
|
||||
and then plays the two passphrase prompts. What it pins:
|
||||
- **The passphrase never reaches argv or the environment**, asserted against
|
||||
what the kernel would have shown any other process — and it DOES arrive down
|
||||
the terminal, twice, or ssh-keygen would have stalled at the second prompt.
|
||||
It is also absent from the JSON the page parses and from every file under
|
||||
the scratch tree.
|
||||
- **By AST, for every function that carries it**, not only `generate`: the pty
|
||||
write lives in `type_at_keygen`, and a rule that looked only at the caller
|
||||
would have missed the place the value actually goes. No list literal, no
|
||||
dict literal, no `open`/`mkstemp`/`write_text`.
|
||||
- **The environment ssh-keygen runs in**, read back out of `/proc`:
|
||||
`SSH_ASKPASS_REQUIRE=never` (this desktop sets `prefer`, which draws a
|
||||
graphical dialog and hangs the pty until the timeout — A found this the hard
|
||||
way) and `LC_ALL=C`, since the prompts are matched by their words.
|
||||
- **Overwrite is refused before ssh-keygen runs**, and the existing key is
|
||||
byte-identical afterwards.
|
||||
- **Ten bad key names**, each refused and each having reached no ssh-keygen,
|
||||
with a file outside the SSH directory left untouched and the scratch
|
||||
directory still holding exactly the two files it should.
|
||||
- **An empty passphrase needs `--no-passphrase`**, the flag exists, and
|
||||
nothing in the shell passes it.
|
||||
- **`fix-permissions` restores 0600 and is confined**: five bad names refused,
|
||||
and a 0644 file outside the directory still 0644.
|
||||
- **Removal from the agent is reachable now** — helper verb, service function,
|
||||
page call, all three — and `ssh-add` was never invoked by the hermetic half.
|
||||
- **`quickshell/secrets-contract` — RUN END TO END, PASS.** Existing pins kept.
|
||||
New: **the Copy button is only ever Copy.** The page used to run one
|
||||
confirmation state for the whole card and label the neighbouring button
|
||||
`confirming ? "Cancel" : "Copy"`, so arming a Forget replaced the word "Copy"
|
||||
in the exact place the user had just learnt to find it — and the way out of a
|
||||
confirmation was to press the button that copies. Pinned from the button
|
||||
block: its label is the literal `"Copy"`, its handler does not read
|
||||
`confirming`, and no button both copies and forgets. Also that the copy
|
||||
confirmation is tied to the row it happened on
|
||||
(`Keyring.copiedPath === …itemPath`) rather than being one card-wide flag.
|
||||
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (8 handoffs against 39
|
||||
pages). `privacy` joins `OWNED`, with the reason: the card that carried the
|
||||
door was headed "Owned by Fedora" and explained that GNOME's file-history
|
||||
switches would not take effect in a Hyprland session anyway. Plus the inverse,
|
||||
said from the page's side — `PrivacyPage.qml` contains no `openGnomePanel` at
|
||||
all — because the generic loop can only fail on a door that exists.
|
||||
- **`quickshell/settings-ownership-contract` — RUN, PASS.** The
|
||||
`lockMinutes`/`lockOnSleep` mirrors onto `privacy` are gone from the expected
|
||||
table and from the README loop; `modules/settings/README.md` lost the two
|
||||
mirror rows with them. That was the one mirror in the table nobody had asked
|
||||
for: Privacy carried a whole second Screen-lock card, so one preference had
|
||||
two sliders.
|
||||
- **`setup/readme-contract` — RUN, PASS** ("173 contracts, as documented").
|
||||
- **Also run and passing, unchanged by this phase but touching the two rebuilt
|
||||
pages**: `qmldir-registration-contract` (185 components, including B's new
|
||||
`PrivacyLiveTile` and `SectionLabel`), `search-routing-contract` (144 routed
|
||||
settings), `settings-nav-contract`, `settings-buttons-contract`,
|
||||
`settings-docs-contract` (172 settings documented — the schema did not
|
||||
change, so no docs regeneration was needed), `settings-hardcoded-values-contract`,
|
||||
`settings-jump-contract`.
|
||||
|
||||
### Deferred, and why
|
||||
|
||||
- **`quickshell/lock-screen-settings-contract` — NOT RUN.** Its second half
|
||||
daemonizes a Quickshell preview harness and counts `hyprlock` processes, which
|
||||
is exactly the kind of thing that should not happen underneath a live desktop
|
||||
session. Its first half was edited and is source-only: the "Screen lock" card
|
||||
must not exist on Privacy, `lockMinutes`/`lockMinutesBattery`/`lockOnSleep`
|
||||
must each be bound on Power and on Privacy nowhere, and Privacy must point at
|
||||
Power & Lock rather than silently dropping the card. All five were verified by
|
||||
hand against the landed `PrivacyPage.qml` and `PowerPage.qml`; the harness
|
||||
half was not exercised.
|
||||
- **`quickshell/settings-search-contract` — NOT RUN**: it starts a Quickshell
|
||||
harness. The nine new entries were checked statically — parsed out of
|
||||
`SettingsSearch.qml` and confirmed to route to `privacy` and `ssh-keys`, both
|
||||
of which `settings-nav-contract` confirms are leaves.
|
||||
- **`quickshell/settings-pages-contract` and `settings-write-sweep-contract` —
|
||||
NOT RUN**: both load the QML. Neither page has been rendered by anything in
|
||||
this phase.
|
||||
- **`Traces.qml` is pinned but never loaded.** Its seam
|
||||
(`PANAMA_PRIVACY_HELPER`) and its lack of a trash path are checked from the
|
||||
source; no contract here constructs the singleton.
|
||||
- **A deliberate duplicate label in search.** "Application permissions" now
|
||||
appears twice — once routing to Applications (what a Flatpak's sandbox
|
||||
exposes) and once to Privacy (what the portal recorded). They are different
|
||||
questions that share a name, both cards are titled that, and the dedupe key
|
||||
includes the page, so both survive. Worth a look on the rendered sidebar.
|
||||
- **Seams pinned by name**: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`,
|
||||
`PANAMA_SSH_KEYS_HELPER`, `PANAMA_PRIVACY_RECENTS`,
|
||||
`PANAMA_PRIVACY_THUMBNAILS`. Both privacy seams re-confine against `HOME`, so
|
||||
a hermetic run points `HOME` at a scratch tree rather than pointing the seams
|
||||
out of it; `panama-ssh-keys` reads `Path.home()`, so `HOME` alone redirects it.
|
||||
- Run order for this phase: the hermetic ones first (`permissions-contract`,
|
||||
`privacy-traces-contract`, `ssh-keys-contract`), then the source-only ones
|
||||
(`gnome-handoff-contract`, `settings-ownership-contract`,
|
||||
`setup/readme-contract`, `search-routing-contract`), then the read-only system
|
||||
one (`secrets-contract`, which lists the live keyring), then the harness ones
|
||||
(`settings-search-contract`, `lock-screen-settings-contract`), and
|
||||
`settings-pages-contract` last, as before.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Privacy & Security redesign — all eight tables
|
||||
|
||||
Approved mock: `home-mocks/privacy.html` (scratchpad, :8642). Spec wins over mock on conflict.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **One subject for eyes and ears**: live PipeWire tiles + portal permission rows unified —
|
||||
camera, microphone, screen sharing (screencast table), remote desktop — plus the background
|
||||
table (7 live rows) as its own card. The sandboxing honesty note stays.
|
||||
2. **Traces clear natively**: recent files, thumbnails, trash (reusing Disks) — the
|
||||
`openGnomePanel("privacy")` punt dies and privacy becomes Panama-OWNED.
|
||||
3. **Secrets polish**: per-action confirm state (Copy stops doubling as Cancel), copy feedback,
|
||||
friendly item descriptions.
|
||||
4. **SSH Keys complete**: generation with a pty-fed passphrase, agent remove wired (with the
|
||||
keyring-agent honesty as prose), Fix-permissions action, copy feedback, visible empty states,
|
||||
refresh rows on both pages.
|
||||
5. The duplicated Screen-lock card is replaced by an "Elsewhere" pointer card (Power & Lock,
|
||||
Notifications).
|
||||
|
||||
Non-goals: location (geoclue absent — render the section only if the table has entries),
|
||||
notifications portal table (NotificationsPage owns the subject), documents-portal grants,
|
||||
telemetry/USB-protection gsettings (inert without their GNOME daemons — the trap the page's own
|
||||
header warns about), known-hosts hashed-entry removal, keyring collection management.
|
||||
|
||||
## Helpers (pinned)
|
||||
|
||||
**`scripts/panama-permissions`** — generalized from 3 hardcoded devices to tables:
|
||||
- `snapshot` → `{ tables: { camera: [...], microphone: [...], screencast: [...],
|
||||
"remote-desktop": [...], background: [...], location: [...] }, available, error }`; each row
|
||||
`{ app, allowed }`. camera/microphone stay the `devices` table's simple yes/no; screencast /
|
||||
remote-desktop values are structured GVariants — those rows report presence only and support
|
||||
**revoke only** (`DeletePermission`), never Set (pinned: the page must not offer a toggle it
|
||||
cannot honor). background is plain yes/no (toggleable). location read-only listing.
|
||||
- `set TABLE APP true|false` (only for simple-valued tables: camera, microphone, background),
|
||||
`forget TABLE APP` (all tables). Table and app ids validated.
|
||||
|
||||
**NEW `scripts/panama-privacy`** — traces:
|
||||
- `traces` → `{ recents: { bytes, entries }, thumbnails: { bytes }, error }` (du-based,
|
||||
budgeted).
|
||||
- `clear-recents` — truncate `~/.local/share/recently-used.xbel` to an empty valid xbel
|
||||
document (not delete — GTK recreates but an empty valid file takes effect instantly).
|
||||
- `clear-thumbnails` — guarded removal inside `~/.cache/thumbnails` only (resolve, refuse
|
||||
symlink escape — the panama-disks guard pattern).
|
||||
- Trash is NOT here — the page reuses `Disks.clean("trash")` / its cleanable byte count.
|
||||
|
||||
**`scripts/panama-ssh-keys`**:
|
||||
- `generate NAME COMMENT` — ed25519 only; NAME validated `^[A-Za-z0-9_.-]{1,64}$`, confined to
|
||||
`~/.ssh`, refuses overwrite; **passphrase read from stdin by the helper, handed to ssh-keygen
|
||||
over a pty — never argv, never a temp file** (empty passphrase allowed but the UI requires
|
||||
non-empty; helper accepts empty only with an explicit `--no-passphrase` flag the UI never
|
||||
passes). Returns the fresh snapshot.
|
||||
- `fix-permissions NAME` — chmod 600, same confinement, returns fresh snapshot.
|
||||
- `agent-remove` exists; unchanged.
|
||||
|
||||
## Services (A)
|
||||
|
||||
- **Permissions.qml**: `tables` model per the snapshot; `setPermission(table, app, allowed)`,
|
||||
`revoke(table, app)`; per-table helpers the UI needs (`simpleTables`, `revokeOnlyTables`).
|
||||
- **NEW `Traces.qml`**: `recentsBytes/entries`, `thumbnailsBytes`, `measured`, `measure()`,
|
||||
`clearRecents()`, `clearThumbnails()`, `busy/lastError`; seam `PANAMA_PRIVACY_HELPER`.
|
||||
- **SshKeys.qml**: `generate(name, comment, passphrase)` (passphrase via Process stdin),
|
||||
`removeFromAgent(path)` (wires the existing verb; surfaces the durableRemoval refusal
|
||||
message), `fixPermissions(name)`, copy feedback (`copiedKey` cleared by a timer, the Keyring
|
||||
`copiedPath` pattern), `refresh()` exposed for a page row.
|
||||
- **Keyring.qml**: no changes expected; the confirm-state fix is page-side.
|
||||
|
||||
## UI (B)
|
||||
|
||||
**PrivacyPage.qml** rebuilt (gains `objectName: "privacy"`): unified Camera/mic/screen card
|
||||
(live tiles from PrivacyState with the in-use warn tone; grouped sections per table with
|
||||
uppercase labels + counts; camera/mic rows Ask-again + toggle; screencast/remote-desktop rows
|
||||
detail-explained with two-stage Revoke; empty-section honesty lines; the "not sandboxed and
|
||||
never ask" note); Run in the background card (all rows, toggles); Saved passwords & secrets
|
||||
(unlock state row + lazy Saved-items with per-row copy feedback and per-row confirm state —
|
||||
separate `confirmingItem` from the copy path); Traces card (recents/thumbnails from Traces,
|
||||
trash from Disks cleanables with "the same Trash Storage cleans" detail, clipboard-history
|
||||
pointer row); Device security card + "Check again" refresh row; Elsewhere card (Power & Lock,
|
||||
Notifications pointers). Location section only when the table is non-empty. The Screen-lock
|
||||
card is DELETED. No `openGnomePanel` calls remain.
|
||||
|
||||
**SshKeysPage.qml** rebuilt: error rows into cards; Your keys card (rows + copy feedback +
|
||||
generate flow — name/comment LiveFieldRows, two SecretFieldRows with match validation,
|
||||
Create key disabled until valid + matching); Agent card (held keys with Remove, the
|
||||
design-not-a-bug prose when gnome-keyring); over-permissive warning card gains Fix
|
||||
permissions; Known hosts card always visible with an empty state; a refresh row.
|
||||
|
||||
## Search & docs (C)
|
||||
|
||||
New entries: Background apps, Screen sharing permission, Remote desktop permission, Clear
|
||||
recent files, Thumbnails, Application permissions (privacy) → privacy; Generate an SSH key,
|
||||
Fix key permissions, Remove from agent → ssh-keys. Docs regen only if schema changes (none —
|
||||
verify).
|
||||
|
||||
## Contracts (C — write; hermetic runs only)
|
||||
|
||||
- `permissions-contract`: tables model; the revoke-only rule for structured tables (no Set path
|
||||
for screencast/remote-desktop anywhere — AST pin); the page never claims more than the portal
|
||||
enforces (kept); validated table/app ids.
|
||||
- NEW `privacy-traces-contract`: hermetic — clear-recents writes a valid empty xbel (never
|
||||
deletes), clear-thumbnails guarded (symlink escape refused), no urgency language in the
|
||||
Traces card copy (the anti-racket stance), trash reuses Disks (no second trash
|
||||
implementation — grep pin).
|
||||
- `secrets-contract`: extend — per-row confirm state separated from copy (the collision pin),
|
||||
copy feedback present; all existing pins kept.
|
||||
- `ssh-keys-contract`: extend — generate's passphrase never in argv (AST + runtime with a
|
||||
recording stub), pty usage pinned, name confinement + overwrite refusal, fix-permissions
|
||||
confinement, agent-remove reachable from QML now + the honesty prose, copy feedback; all
|
||||
existing pins kept (private keys never read, passphrase rule).
|
||||
- `gnome-handoff-contract`: `privacy` becomes OWNED; verify no page hands off to it.
|
||||
- `lock-screen-settings-contract`: reconcile with the lock card's removal from PrivacyPage.
|
||||
- Backlog Phase 12; README count line (172 → 173 expected).
|
||||
|
||||
## Agent ownership (parallel)
|
||||
|
||||
- **A**: `scripts/panama-permissions`, NEW `scripts/panama-privacy`, `scripts/panama-ssh-keys`,
|
||||
`services/Permissions.qml`, NEW `services/Traces.qml`, `services/SshKeys.qml`.
|
||||
- **B**: `modules/settings/PrivacyPage.qml`, `SshKeysPage.qml`, new components (+ qmldir).
|
||||
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
|
||||
|
||||
## As built (A) — refinements to the pinned APIs
|
||||
|
||||
Read the real permission store before finalizing, and two things there were not
|
||||
what the spec assumed:
|
||||
|
||||
- **screencast / remote-desktop ids are opaque restore tokens**, one per
|
||||
remembered session, not the table name. So the helper `List`s the table,
|
||||
`Lookup`s each token, and folds the result **by application** — one row per
|
||||
app, with `grants` counting the stored sessions behind it. `forget TABLE APP`
|
||||
drops every one of them, which keeps the pinned `revoke(table, app)`
|
||||
signature honest.
|
||||
- **`speakers` is gone.** The spec names six tables and speakers is not among
|
||||
them; the old three-device model is replaced wholesale.
|
||||
|
||||
Additive to the pinned shapes (nothing removed):
|
||||
|
||||
- `snapshot` rows carry `grants` (int) and `raw` (string) beside `app`/`allowed`;
|
||||
the payload carries `simpleTables` and `revokeOnlyTables` so the service never
|
||||
hardcodes a list the helper could change.
|
||||
- `set TABLE APP true|false` (not `allow|deny` — the old CLI's words).
|
||||
- `traces` sub-objects carry `path`, `present`, and (thumbnails) `measured`,
|
||||
which is false when the walk hit its budget and the byte count is a floor.
|
||||
- `Traces` does **not** measure on startup. `measured` is false until the page
|
||||
calls `measure()`.
|
||||
- `Permissions.devices` survives as a derived camera/microphone view, because
|
||||
ApplicationsPage reads it to answer "does this app have a privacy rule".
|
||||
- `panama-ssh-keys generate` runs ssh-keygen with `SSH_ASKPASS_REQUIRE=never`
|
||||
and no `DISPLAY`: this desktop sets `SSH_ASKPASS_REQUIRE=prefer`, which made
|
||||
ssh-keygen open a graphical dialog and ignore the terminal entirely.
|
||||
- Helper seams: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`,
|
||||
`PANAMA_SSH_KEYS_HELPER`. Fixture seams: `PANAMA_PRIVACY_RECENTS`,
|
||||
`PANAMA_PRIVACY_THUMBNAILS` (both still confined to `HOME`, so a hermetic run
|
||||
points `HOME` at a scratch directory).
|
||||
|
||||
Hard rules: NO live mutations — no portal Set/Delete, no keyring writes, no ssh-keygen runs
|
||||
against the real ~/.ssh, no chmod, no truncating the real recents, no thumbnail deletion.
|
||||
Read-only probes and hermetic stubs only. Valid QML/Python at every save. B programs against
|
||||
the pinned APIs; A updates this spec before changing them.
|
||||
Reference in New Issue
Block a user