Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.
72
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
This file documents the internals of slack watch and of slack post's 1-hour
reply auto-watch. You do not need to read it to use those commands — SKILL.md
covers the flags and the observable behaviour. Read this when debugging a watch
that stopped firing, when changing the implementation, or when you need to know
exactly what state and background tasks a watch leaves behind.
Slack servers → wss://*.slack.com/ → Browser WebSocket
↓
Runtime WebSocket observer (declarative filter: type=message + channel/thread)
↓
forward → SLICC webhook (closed-enum sink)
↓
SLICC delivers lick event to target scoopStep by step, both for a manual slack watch and for slack post's auto-watch:
browser.websocket runtime API — no page-context code injection, no
prototype patching.wss://*.slack.com/ connections carry all real-time events (messages,
typing indicators, etc.).type: "message" frames matching the watched channel
(and thread, if specified).Because nothing polls, the mechanism is silent by construction: when nobody says anything, no frame arrives, so the scoop is never woken. There are no ticks and no per-minute wakes; a lick means a real message matched.
Registration is a three-call chain (subscribeWatch() in scripts/slack.jsh):
browser.websocket
.on(tab, { urlMatch: /slack\.com/ })
.filter({ parseAs: 'json', where: { type: 'message', channel: '<channel>' /*, thread_ts */ } })
.forward({ sink: 'webhook', webhookId: '<id>' });urlMatch is constrained to /slack\.com/ so no cross-origin socket is ever
matched (defence against cross-origin capture bleed).where selector is a deep-equality subset match, equivalent to the guard
data.type === 'message' && data.channel === w.channel && (!w.thread_ts || data.thread_ts === w.thread_ts). A thread_ts key is added only for a
thread-scoped watch.The predecessor implementation monkey-patched WebSocket.prototype.send inside the
Slack page and posted a reshaped { type: 'slack-watch', ... } envelope to a
webhook URL. That patch was a P0 security finding and is gone. Consequence for
consumers: the sink now receives the raw Slack message frame the router
matched, not the old envelope. The per-channel/thread subscription makes the watch
context implicit, and the full frame is a superset of the old envelope's event.
The observer subscription is owned by the runtime's page-side router and is tied to the tab, not to the jsh process that registered it. Verified live. Three consequences:
slack post set
up a watch and return immediately, and what lets it keep forwarding real reply
frames afterwards.browser.websocket .list() offers no cross-process close.So teardown cannot revoke the subscription. Instead, deleting the webhook is the kill-switch: once the webhook is gone the observer's sink no longer resolves, so matched frames are silently dropped. The orphaned subscription lingers in the page router until the Slack tab reloads, but it is an inert sink — no lick can reach anyone. This is also why the auto-watch never deletes and recreates a webhook to change its filter: that would orphan the observer with a live, unreachable sink.
Discovery requires an outbound send() on the socket: the runtime router wraps a
WebSocket instance the first time the page calls send() on it. A receive-only
socket that was established before the subscription was registered is therefore
captured only on its next outbound frame. Slack sends ping keepalives roughly every
10 seconds, so an existing connection is picked up within one ping cycle — but a
message arriving in the first few seconds after registration can be missed. This
matches the discovery semantics of the removed prototype patch.
slack post's auto-watch attaches a --filter to its webhook so that only real new
replies wake the scoop. The webhook event carries the matched Slack frame under
e.body (the observer forwards the frame; the webhook wraps it), and the filter
keeps the defensive shape (e && e.body) || e || {} so it works either way. It is a
self-contained string with every value JSON-inlined — it closes over nothing:
(e) => { const m = (e && e.body) || e || {};
if (m.type !== 'message') return false;
if (m.subtype) return false; // message_replied parent updates, joins, edits
if (m.ts === "<selfTs>") return false; // echo of the message that created the watch
if (m.user === "<selfUser>") return false; // anything WE post, for the whole hour
return true; }selfTs is the ts of the message that started the watch.selfUser is our own Slack user id, resolved once at watch-creation time via the
auth.test Web API method (user_id). The call is non-fatal: if it fails or
returns no user_id, the m.user clause is omitted and the filter degrades to
timestamp-only dropping. Posting never fails because of it.--filter is fixed at webhook create
(the CLI has create/list/delete, no update), and the filter cannot be
swapped later without deleting the webhook and orphaning the observer.The resolved id is persisted as selfUser in the watch state file so the state
stays self-describing.
conversations.info num_membersThe auto-watch looks up the resolved channel with conversations.info and reads
channel.num_members:
thread_ts === <threadRoot>, to avoid a firehose on a big channel.channel plus a thread_ts, so whole-channel scope
covers both channel messages and thread replies.A missing num_members (DMs and some conversation types omit it, and a failed
lookup is swallowed) is treated as «small». The thread root is the --thread_ts
that was replied into, or — for a fresh top-level post — the new message's own ts.
The watch id is derived from the scope decision: <channel>-<threadTs> for a
thread-scoped watch, plain <channel> for a channel-scoped one. This is the same
deterministic id scheme slack watch uses, so slack unwatch <channel> /
--thread=<ts> addresses an auto-watch exactly like a manual one.
Replies route back to the cone that posted. The target resolution order is:
--watch-scoop=<name> (per-post override; --scoop=<name> for slack watch).SLICC_LICK_TARGET from the process environment — the runtime sets it in the
shell env of every cone that is not the default root, naming that cone's own
scoop folder (slicc packages/webapp/src/shell/lick-target-env.ts,
scoops/scoop-context/shell-env.ts). Environment variables do reach .jsh
scripts; older notes claiming otherwise are stale.Case 3 matters: the untargeted destination is rootsOf(scoops)[0], the oldest
root, which is deliberately not necessarily whichever cone currently holds the
reserved cone folder (that folder is recycled to the next new cone when the
original primary is dropped). So hardcoding cone is wrong in principle, not only
in a multi-cone workspace — which is why the teardown crontask omits --scoop
entirely when there is no target.
The webhook cannot do that yet: crontask create accepts an omitted --scoop,
while webhook create rejects it with --scoop is required (exit 1). That
asymmetry is slicc#2525; until it lands, webhook create keeps passing the
literal cone (WEBHOOK_FALLBACK_SCOOP) when there is no target. This is a
knowing stopgap, not an oversight — when slicc#2525 lands, delete the constant and
omit the flag exactly as scheduleTeardown() already does.
Whatever value ends up being interpolated goes through validateScoopName() first,
including one read from the environment: it lands in an exec() shell command
string, so an unvalidated value is a shell-injection vector regardless of source.
If the runtime rejects the resolved target as a webhook target, the auto-watch warns
and falls back to a standing relay scoop slack-reply-watch, auto-created if
missing — but never for an explicitly requested --watch-scoop, where the caller
wants that scoop or a clear failure.
If the observer registration fails after the webhook was created, the webhook is deleted again (roll-back) and the post still succeeds with a warning — the whole auto-watch path is non-fatal.
expiresAt (now + 3600 s) and the teardown task id are stored in the watch state
file. A one-shot crontask named slack-autowatch-teardown-<watchId> is scheduled
about 60 minutes out. When it fires it delivers a self-describing lick to the watch
scoop naming the exact commands to run:
{
"kind": "slack-autowatch-teardown",
"watchId": "<watchId>",
"instruction": "The 1h Slack reply auto-watch has expired. Run these commands then stop.",
"commands": ["slack unwatch <channel> [--thread=<ts>]", "crontask delete <taskName>"]
}slack unwatch deletes the webhook (the kill-switch) and the state file; the second
command deletes the teardown task itself. So nothing runs or delivers past the hour.
Local-time cron computation. The cron expression pins minute, hour, day-of-month
and month (<min> <hour> <dom> <month> *) so the task fires exactly once. The
fields are read from new Date() in the JS realm, which is local time, because
the scheduler evaluates cron in local time — bash date must not be used here, it
is UTC in this environment. Pinning day and month means a same-time recurrence next
month would be the only repeat, and the teardown deletes itself before that.
Posting again into a channel or thread that is already under an active auto-watch
extends the existing watch instead of erroring or duplicating the
webhook/observer: the old teardown crontask is deleted, a new one is scheduled
another hour out, and expiresAt is rewritten in the state file. The webhook, its
filter and the observer subscription are untouched (they cannot be updated — see
above), which is precisely why the filter drops by user id rather than by a single
timestamp: otherwise the extending post would itself be forwarded as a reply.
Output on the extend path is Extended reply watch for <watchId> to 1h (routes to <scoop>).
Every cone in the workspace shares /workspace/skills/, and the state file names
are keyed by workspace + channel with no cone component. So a channel has exactly
one watch, and it belongs to whichever cone created it. The files therefore record
the owner in lickTarget (null = the default root; absent = written before
ownership tracking, treated as unknown so old files never trip the checks):
slack post into a channel another cone is watching still extends the hour and
keeps that cone's routing, but warns <watchId> is watched by <owner>, not <us> and prints the slack watch … --force takeover command. It never
re-points the watch silently — the silent version of this is exactly the bug
that delivered cone-helix's replies to cone.slack watch without --force names the owner in the "Already watching" refusal
and says explicitly when replacing it would cut off another cone's replies. With
--force it deletes the previous watch's teardown crontask as well as its
webhook: that task fires against the deterministic watch id, so a survivor
would later run slack unwatch and delete the replacement watch about an hour
after the takeover.slack watches appends [owner: <cone>] when the owner differs from the
webhook's scoop..last-post-<workspace>-<channel>.json records the posting cone — its own
SLICC_LICK_TARGET, never a --watch-scoop override, since redirecting replies
does not change who posted. (Recording the override instead would make a cone
refuse to thread onto its own earlier post, and make two cones sharing one
override look like a single owner.) So
--thread_ts=last refuses (and prints the timestamp for an explicit
--thread_ts=<ts>) rather than threading under a message a different cone posted.Sharding the filenames per cone was rejected: it would hide other cones' watches
from slack watches and break slack unwatch across cones, trading a visible
conflict for an invisible one.
One JSON file per watch, at /workspace/skills/slack/.watch-<watchId>.json. Fields
written by the auto-watch (a manual slack watch writes the same shape minus the
autowatch*, selfTs, selfUser, threadRoot, expiresAt and teardownTaskId
fields):
| Field | Meaning |
|---|---|
watchId | <channel> or <channel>-<threadTs> — deterministic, prevents duplicates |
channel | Resolved conversation id (C…/D…/G…) |
thread_ts | Thread root when thread-scoped, else null |
scoop | Scoop the webhook was actually given (the resolved target, else cone — slicc#2525) |
lickTarget | Cone that owns the watch: its SLICC_LICK_TARGET, or null for the default root |
workspace | Team/enterprise id the watch belongs to |
createdAt | ISO timestamp of creation |
autowatch | true for a slack post auto-watch (absent for manual watches) |
delivery | ws — event-driven WebSocket observer delivery |
autowatchScope | thread or channel, from the num_members decision |
threadRoot | Thread root ts considered when the watch was created |
selfTs | ts of the message that created the watch (dropped by the filter) |
selfUser | Our own Slack user id from auth.test, or null (dropped by the filter) |
webhookId | SLICC webhook id — deleting it is the delivery kill-switch |
webhookUrl | Webhook URL |
filter | The exact --filter JS string handed to webhook create |
expiresAt | ISO expiry (creation + 1 h, rewritten on extension) |
teardownTaskId | Crontask id of the one-shot teardown, or null |
subId | Observer subscription id returned by the runtime |
slack watches reads these files; slack unwatch deletes the webhook, the teardown
crontask if present, and the file.
The observers live in the Slack tab. If the page reloads, they are gone while the
webhooks and state files survive — the watch looks active in slack watches but no
licks arrive. Run slack reinject to read all active watch state files and
re-register one observer per watch. Same fix if watches simply stop firing.