feat(governance): add capabilities.social_share pin for share cards - #8565
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS The withdrawal path is honest and protective: the notice names what happened, salvages the user's edits, and the entry disappears cleanly when pinned off. The mid-compose screenshot confirms the hard case works: "Sharing was turned off by policy. Copy your text now…" asserts the state the code holds, offers an adjacent "Copy text" action with copied/failed feedback, and the clipboard-blocked fallback pre-selects the caption. Menu withdrawal avoids an empty dropdown, the modal survives the policy flip without destroying edits, and the Security panel row is named after the menu entry a user actually lost. All 12 locales carry the new strings. [UX-REVIEWED] 8a9e8a9 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A UI-only affordance is governed at the only chokepoint it has, using the established capability-row shape, with the gaps disclosed rather than papered over. Watch
Suggestions
[DESIGN-REVIEWED] 8a9e8a9 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verification checks are done: First-Principles-Verdict: PASS A fleet-forbiddable egress path (caption text to X/LinkedIn) gets its one possible chokepoint — the entry itself — with every item declared and derived. What this change shipsIntent: let a fleet administrator turn off the "Share as image" feature wholesale. ADDITION.
Counts run: probe shape matches [FIRST-PRINCIPLES-REVIEWED] 8a9e8a9 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @CrysisDeu overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
b9754a0 to
0b75187
Compare
0b75187 to
4f8023f
Compare
4f8023f to
455b69b
Compare
455b69b to
146c747
Compare
146c747 to
8c23281
Compare
|
/ai-review override gpt 8c23281: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
8c23281 to
6d70ae2
Compare
|
/ai-review override gpt 6d70ae2: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
6d70ae2 to
ffc0acf
Compare
|
/ai-review override gpt ffc0acf: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
ffc0acf to
fc1164b
Compare
|
/ai-review override gpt fc1164b: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
fc1164b to
b6dc1b3
Compare
|
/ai-review override gpt b6dc1b3: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
b6dc1b3 to
927d248
Compare
|
/ai-review override gpt 927d248: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
"Share as image" turns an assistant reply into a branded PNG card and offers a prefilled post to X / LinkedIn. The card is rendered and exported in the browser (copy and download never leave the machine), but the intent buttons hand the caption text to a third-party site in a URL, so the entry is an egress path for agent output that a managed fleet must be able to close. Add a capabilities.social_share SCOPE_CATALOG row (default on, data only). There is no server-side share action to refuse, so the control is the dashboard entry: GET /api/dashboard/config reports a read-only social_share_enabled, evaluated on the pinned dashboard surface through vet_and_audit (every layer honoured, every decision audited, fail-closed - the shape the mobile-connect listing uses), and the chat draws the menu item only when it is true. A menu that would then hold nothing (loaded window, fork/plan as row buttons) is withdrawn with it. The WS slots frame carries the governance-ceiling generation so a centrally pushed policy invalidates the cached answer instead of waiting out its stale window. The field is dropped from the PUT body so it can never be written.
927d248 to
8a9e8a9
Compare
|
/ai-review override gpt 8a9e8a9: Profile-layer tightening not bumping governanceGeneration is a deliberate, documented limitation (PR body "Known limitation", follow-up #8623): profiles are operator-local hand-edited files, not the fleet push channel; the policy channel is fully covered; a profile change converges within the 30s staleTime / window focus / next slot mutation — the same contract every other dashboardConfig-derived field (gitlab_hosts, jira_hosts) already has. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Summary
Adds a governance switch for the "Share as image" feature (PR #8040) so a fleet administrator can turn it off wholesale: a new
capabilities.social_sharecapability row, resolved server-side and enforced at the only place the feature can be enforced — the dashboard entry.{"version": 1, "boot": {"fail_closed": true}, "capabilities": {"social_share": {"enabled": false}}}What the feature sends off the machine (the point of the switch)
Audit of
website/src/pages/chat/share/:html-to-imageover a DOM node). The PNG is copied to the clipboard or downloaded. No upload anywhere.https://x.com/intent/post?text=…andhttps://www.linkedin.com/feed/?shareActive=true&text=…in a new tab. The caption text (derived from the assistant reply) leaves the machine in the URL to a third-party site. This is the egress governance needs to be able to close./logo.png— fetched from the gateway itself (same origin), not egress.There is no server-side share handler (no render, store, or POST endpoint exists), so there is no backend action to return 403 from. The control is the entry itself.
How it is enforced
Backend
capabilities.social_shareSCOPE_CATALOGrow,capability_default=True, data-only (noCONTRACT_VERSION/ evaluator change). A policy that never mentions the row keeps the entry; naming it withenabled: falsewithdraws it.src/kiro_crew/dashboard/social_share.py— the probe, the shapehandlers/mobile_connect.pyuses for the same job (a dashboard read that hides an entry): evaluated throughvet_and_auditon the pinneddashboard:uisurface key (never a caller-controlled header), every denied decision honoured whichever layer produced it (a profile bound to the dashboard surface can withdraw the entry), and fail-closed (an unevaluable ceiling hides the entry and is recorded as the denial it produces).GET /api/dashboard/configgains a read-onlysocial_share_enabled. Resolved off-thread. The field is dropped from thePUTbody (both settings surfaces round-trip the whole GET), so it can never be written — governance, not config, owns the answer.governance_decisionrow (tooldashboard_config_social_share), grant and denial alike — the read is the enforcement here. The endpoint is not polled (mount / focus / stale window / generation change only).policy_distribution.apply_ceilingcan replace the ceiling without a restart, so the WSslotsframe now carriesgovernanceGenerationalongsidegitlabHostsGeneration(one builder, both frames). Every dashboard-user socket's existing 5s status pusher (_push_status) compares the counter each tick and requests a coalesced slots push when it moves — no extra task per socket (First Principles subtraction adopted), and kept out of the owner-only credential-refresh driver so a non-owner-only window is reached too. The initial frame and the tick's baseline are seeded from ONE read, so a swap during connection setup registers as a change rather than becoming the baseline silently.useWebSocketinvalidates['dashboardConfig']on a change and on each connection's first frame. A withdrawn entry disappears within one 5s tick instead of waiting out the 30s stale window.openIntentre-reads the permission from a ref AFTER the export awaits; if policy withdrew sharing meanwhile, the pre-opened tab is closed and the caption is never handed to the third-party URL.Known limitation (deliberate): the invalidation channel follows the ceiling generation, which
set_contextbumps on every policy install. A profile-layer (Level 2) tightening — an operator editing a local profile file — is honoured by the probe but does not bump that counter, so the dashboard converges on the cacheddashboardConfig's schedule (30s stale window, window focus, or the next slot mutation) rather than on the next watcher tick. Profiles are operator-local files, not the fleet push channel, and every otherdashboardConfig-derived field (gitlab_hosts,jira_hosts) converges on the same schedule. Tracked as a follow-up in #8623 (aprofile_generation()bumped by theProfileStoreon reload, compared alongside the ceiling generation).Frontend
AssistantMessagetakesshareEnabled; ChatPage passesdashCfg.social_share_enabled === truefrom the['dashboardConfig']query it already runs — no new fetch. The entry stays hidden until the server has answered (endpoint is the authority, same posture as the mobile-connect rail row). An absent prop also hides it, so a host that forgets the wire fails closed.More actionstrigger is withdrawn with it rather than opening an empty menu. Fork/plan are untouched by the pin.shareEnabled). The user's edited caption stays in place, the four share actions are disabled, and a notice says why and tells the user to copy their text now — it is not saved on close (pages.chat.share.withdrawn_by_policy, 12 locales + en-XA). A plain-text Copy text affordance in the notice (local clipboard, no image, no third-party site) keeps one salvage path, so Close never means silent loss; it copies everything the user could have edited here — the caption AND the card's contenteditable text (question when included, excerpt), since those edits vanish on close just like the caption does. When the clipboard refuses (plain-HTTP hosts; a thrown legacy fallback is caught too) the button reports Copy failed, a status line says the clipboard is blocked, that the post text is selected — press Ctrl+C / ⌘C — and that card edits must be copied from the card itself, and the caption is selected so that keystroke is the next thing the user does — the one button the notice points at never sits inert. The notice is actor-neutral ("Sharing was turned off by policy") because the probe honours an operator's own profile pin as well as a fleet ceiling (all UX suggestions on the prior heads, implemented).SCOPE_LABEL_KEYinSecurityPanel.tsxgainscapabilities.social_share→gov_scope_social_share("Share as image", same string as the menu entry, 12 locales + en-XA), so the Security panel's row — the one place a user can learn why Share vanished — names the feature they lost instead of falling through to the untranslated "Social Share" leaf.api_governance_policyiteratesSCOPE_CATALOG; label falls back to the humanised leaf "Social share").Screenshots
Same reply, same hover — the only difference is the stubbed
social_share_enabled.Permitted (default):
More actionsopens with Share as image.Pinned off by policy: the action row keeps copy / link / fork / plan / speak / raw / regenerate; the
More actionstrigger (whose only item was Share) is gone.Policy withdrawn while composing: the dialog stays with the edited caption, the share actions are disabled, the notice names the cause and offers a plain-text copy.
Captured by
website/scripts/capture-social-share-governance.mjs(fixture harness, no gateway).Docs
docs/system-specs/modules/governance.md— capability list + acapabilities.social_sharesection (why the chokepoint is a read, fail-closed rationale, audit-on-change, policy-layer-only).docs/feature-map/README.md— share row now names the governance scope and the endpoint it is read from.Tests
test/test_social_share_governance.py— catalog row; probe through the real evaluator (ungoverned, policy silent, policy pin, dashboard-bound profile deny, degrade, unexpected error + its audit row); pinned surface key; GET reports true/false; every decision audited; PUT tolerates the round-tripped field and never persists it;_slots_ws_framecarriesgovernanceGeneration.test/test_dashboard_state_ws.py— owner and dashboard frames carry the samegovernanceGeneration.website/src/test/AssistantMessage.test.tsx— Share only while permitted; absent prop fails closed; trigger withdrawn when Share was the only item and returns when permitted.website/src/pages/chat/share/ShareMessageModal.test.tsx— a mid-compose withdrawal keeps the dialog and its edited caption, disables all four share actions, shows the notice, keeps the plain-text copy path working (caption and the edited card text ride along), and does not close on the user's behalf; a refused clipboard on that copy path reports the failure and selects the caption instead of looking like success; a withdrawal that lands during an in-flight export closes the pre-opened tab and never navigates it to the intent URL.website/src/test/useWebSocketGitlabHosts.test.ts—dashboardConfiginvalidated on a governance-generation change and on a reconnect's first frame.website/src/pages/settings/SecurityPanel.test.tsx— a pinnedcapabilities.social_sharerow is labelled "Share as image", not the humanised "Social Share" leaf.Local verification was lint-only per the current desk rule (
isort/flake8/mypy --platform linux/ black gate /tsc -b/eslint/docs-lint/ brand gate all clean); test verification is on CI for this head.no linked issue: governance follow-up requested directly on #8040's feature.