CtrlK
BlogDocsLog inGet started
Tessl Logo

ai-ecoverse/advanced-skills

Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.

72

Quality

90%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

watch-architecture.mdskills/slack/references/

Slack watch architecture

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.

Pipeline

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 scoop

Step by step, both for a manual slack watch and for slack post's auto-watch:

  1. A SLICC webhook is created, routed to the target scoop (one webhook per watch).
  2. A declarative WebSocket observer is registered on the Slack browser tab via the sanctioned browser.websocket runtime API — no page-context code injection, no prototype patching.
  3. Slack's wss://*.slack.com/ connections carry all real-time events (messages, typing indicators, etc.).
  4. The observer filters for type: "message" frames matching the watched channel (and thread, if specified).
  5. Matching frames are forwarded to the webhook, which delivers them as licks to the scoop.

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.

WebSocket observer mechanism and selector shape

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).
  • The 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 sink is a closed enum: skill code supplies a JSON selector plus an existing SLICC webhook id, and can neither author page-context code nor see the inbound frame firehose. The runtime owns the audited, single-source page-side router.

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.

Subscription lifetime, and why deleting the webhook is the kill-switch

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:

  • It survives the creating process's exit — this is what lets slack post set up a watch and return immediately, and what lets it keep forwarding real reply frames afterwards.
  • It survives a tab reload only as far as the router does; after a page reload the observers are gone and must be re-registered (see «Recovery» below).
  • It can only be closed by the process that created itbrowser.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.

Socket-capture timing (the first ≤10 seconds)

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.

Genuine-reply webhook filter (auto-watch only)

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.
  • The own-user clause is what makes «your own messages never notify» hold for the whole life of the watch, and therefore makes TTL extension silent. It must be decided at creation time: a webhook's --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.

Scope decision: conversations.info num_members

The auto-watch looks up the resolved channel with conversations.info and reads channel.num_members:

  • > 100 members → watch the thread only; the observer selector adds thread_ts === <threadRoot>, to avoid a firehose on a big channel.
  • ≤ 100 members, or a DM, or an unknown/missing count → watch the whole channel. A channel message watch also receives thread-reply frames, because those carry a top-level 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.

Routing

Replies route back to the cone that posted. The target resolution order is:

  1. --watch-scoop=<name> (per-post override; --scoop=<name> for slack watch).
  2. 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.
  3. Nothing — no explicit target, and the runtime dispatches the lick to the untargeted destination itself.

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.

One-hour TTL and the one-shot teardown crontask

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.

TTL extension

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>).

Shared state across cones

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.

State files

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):

FieldMeaning
watchId<channel> or <channel>-<threadTs> — deterministic, prevents duplicates
channelResolved conversation id (C…/D…/G…)
thread_tsThread root when thread-scoped, else null
scoopScoop the webhook was actually given (the resolved target, else cone — slicc#2525)
lickTargetCone that owns the watch: its SLICC_LICK_TARGET, or null for the default root
workspaceTeam/enterprise id the watch belongs to
createdAtISO timestamp of creation
autowatchtrue for a slack post auto-watch (absent for manual watches)
deliveryws — event-driven WebSocket observer delivery
autowatchScopethread or channel, from the num_members decision
threadRootThread root ts considered when the watch was created
selfTsts of the message that created the watch (dropped by the filter)
selfUserOur own Slack user id from auth.test, or null (dropped by the filter)
webhookIdSLICC webhook id — deleting it is the delivery kill-switch
webhookUrlWebhook URL
filterThe exact --filter JS string handed to webhook create
expiresAtISO expiry (creation + 1 h, rewritten on extension)
teardownTaskIdCrontask id of the one-shot teardown, or null
subIdObserver subscription id returned by the runtime

slack watches reads these files; slack unwatch deletes the webhook, the teardown crontask if present, and the file.

Recovery after a page reload

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.

tile.json