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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ product overview.

## Unreleased

- Let organization admins use another requester's Slack menu buttons and question forms. Menu
views open under the clicking admin's own identity; question answers continue the original
requester's session and record which admin responded. Current access, membership and stale-form
checks still apply. The same admin access applies to busy-thread and engine-switch/retry cards.

- Restore Composio Remote Workbench for remote data processing and bulk app calls, preserving
workflow session and file references. Explicit Slack membership actions still require human
approval; Python inside Workbench is outside that structured gate. Other generic connector
Expand Down
17 changes: 17 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# ChannelGate — Features

## Admin access to Slack controls

- Current organization admins can open Files, Variables, Settings and legacy menu Resume controls
on another requester's message or `/menu` card. Each new settings/file/variable view belongs to
the admin who clicked; personal credentials, connections and skills use that viewer's identity.
Current channel authorization and membership are checked, and ordinary members remain bound to
their own controls. Private modal navigation and submissions remain bound to their viewer.
- Admins can open, answer, page, submit and cancel another requester's pending question cards and
forms. Both people must retain channel access and membership, and the responder must remain an
admin. Revision checks and single submission still apply. Continuations retain the original
requester's runtime identity and credentials; audit records identify the responding admin
without storing their answer text.
- Admins can also resolve shared busy-thread Steer/Queue/Cancel and engine-switch/retry cards
after current rank, membership and channel access checks. The stored incoming event retains its
author, credentials and runtime permissions; accepted decisions audit the responding admin.
Expiration, duplicate claims and the cross-author automatic steering guard still apply.

## Registered inventory limits and Enterprise harness approvals

- Non-Enterprise installs allow at most 20 registered channels (including DMs and the API
Expand Down
60 changes: 57 additions & 3 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,57 @@
# ChannelGate — Test Plan

## Admin access to Slack buttons and forms (2026-10-23)

- [x] Automated: `node --test test/admin-menu-controls.test.js test/file-button-actions.test.js
test/menu.test.js test/question-admin-access.test.js test/question-interactions.test.js
test/question-continuation.test.js test/scheduled-questions.test.js
test/run-choice-admin-access.test.js test/busy-thread-choice.test.js
test/engine-switch-choice.test.js`.
Real scratch stores and captured Slack calls verify admin overrides for all three menu controls,
actual viewer ownership/personal variable isolation, legacy Resume, question choices, custom text,
multi-select, paged forms, Back, cancel and submit. Foreign ordinary members, demoted admins,
revoked requester access, missing membership, wrong cards/channels and stale revisions fail closed.
Admin answers preserve the original continuation author and audit the responder without answers.
Busy-thread/engine-switch fixtures verify admin access, original incoming-event identity and
responder audit, plus rejection for ordinary foreign members, demotion, removed membership,
dormant access, a different channel and duplicate decisions.
- [x] Final development validation: `npm run test:coverage` — 4,025 passed, 34 skipped,
zero failures; coverage floors passed (lines 94.30%, branches 86.65%, functions 90.28%).
`npm run check:static`, `npm run secret-scan`, `npm run test:security-coverage` and DCO passed.
Corresponding private QA definitions are updated; live acceptance remains unexecuted.
- [ ] Live menu acceptance (engine-independent; no engine turn for these controls): in a disposable
Slack channel, admit an approved member, a second ordinary member and a current organization
admin. Give the first member and admin distinct disposable personal variables. As first member,
post `@agent /menu`. As admin, open Files, Variables and Settings from that card and from an
existing member-authored reply. Require every modal to open; Variables/Settings → You must show
only the admin's personal entries. Open and save a disposable channel-variable form and remove
the variable afterwards. Require audit attribution to the admin and no raw secret in a view.
As second ordinary member, require foreign owned buttons denied. Demote the admin or remove
channel membership and retry: deny the foreign button; also deny stale personal modal actions
after membership is removed. Record Slack view payloads, actor ids and audit event ids.
- [ ] Live question acceptance (repeat with Claude AND Codex): pin each engine in its disposable
Worker fixture. As approved member ask: “Use ask_questions to collect audience and project name
before replying; allow custom text.” As a current member admin, select an option, open custom
text, save and submit. Repeat with six questions to exercise Back/Next; cancel a separate request.
Require one continuation in the original thread/session as the original requester, their personal
scope and project runtime lane, plus `question_admin_action` naming the actual admin with no
answer text. A second ordinary member must be refused. Repeat after admin demotion, requester
access revocation and either participant leaving: no continuation. Stale/replayed submission
must never duplicate a run. Capture question id, Slack payloads, run author/session/runtime,
credential names only and audit events. No live result is claimed by the automated fixtures.

- [ ] Live shared run-choice acceptance (Claude AND Codex fixtures): as a member start a harmless
long turn, then send a second message to obtain the busy-thread card. As member admin click Queue,
then repeat separate fixtures for Steer and Cancel Request. Require one original-author message
queued/accepted (or durably discarded for Cancel), preserved personal scope/runtime lane and
`run_choice_admin_action` naming the admin with no original message text. The existing
cross-author automatic steering guard must remain intact. With ask-before-switch enabled and a
disposable replay-safe fake-provider failure, have the admin click Switch and Try again in
separate fixtures; require the original author to continue once on the selected harness.
Ordinary foreign members, demoted/departed admins, dormant channels and wrong-channel cards
must fail without consuming the pending choice. Repeat a successful click: no duplicate run.
Capture Slack card, pending-choice id, run identity and audit events; no live pass claimed here.

## Email accounts (2026-10-22)

### Regression
Expand Down Expand Up @@ -3393,8 +3445,9 @@ the originating thread under the original author, with no continuation before fi
useful validation response and no continuation. Leave optional notes empty, complete required
fields and submit; require all pages' answers, including the written project name.
- **QST-04 — requester and revision isolation.** With an approved member's pending card, have
another approved member and the admin try to choose, open custom text, and submit. Require
rejection without modifying the request. As requester, open two modal views, change a draft
another approved member try to choose, open custom text, and submit. Require rejection without
modifying the request. A current member admin may answer and submit under the original
requester continuation identity, with the responding admin recorded in audit. As requester, open two modal views, change a draft
through the newer view, then submit the stale view. Require stale-view protection, preservation
of the current draft, and successful submission from refreshed controls. Replay final Submit
and click an answered card: require no duplicate continuation. Revoke the requester's channel
Expand Down Expand Up @@ -3846,7 +3899,8 @@ The skipped/live cases below remain unverified; this branch is not a release can
without changing data. Settings → Resume Session shows existing sessions with their owning harness
and container cwd, including Clean cwd; fresh cases show guidance. On a card posted before this
change, clear the thread and click its old Resume button: no old session command. A channel message `/menu` without mention is ignored;
a revoked/nonmember or different author cannot open an old menu's controls. Capture Slack
a revoked/nonmember or different non-admin author cannot open an old menu's controls; a current
member admin can, under their own viewer identity. Capture Slack
payloads/screenshots, session identity and event evidence. Card delivery is engine-independent;
resume ownership/cwd requires both harness fixtures. Live cases remain unexecuted at development
check time and are required before stable release.
Expand Down
57 changes: 29 additions & 28 deletions src/slack/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@ export async function handleMenuResumeAction({ ack, body, action, client }) {
const userId = body?.user?.id;
try {
const value = JSON.parse(action?.value || "{}");
if (!userId || value.u !== userId || value.c !== channelId || typeof value.t !== "string") {
if (!canOpenMenuButton({ ownerId: value.u, clickerId: userId, clickerIsAdmin: userId ? await isAdmin(userId) : false })
|| value.c !== channelId || typeof value.t !== "string") {
throw new Error("This menu isn't yours. Open your own with `/menu`.");
}
const context = await fileExplorerContext(client, { channelId, userId, verifyMembership: true });
Expand All @@ -370,7 +371,7 @@ export async function handleMenuResumeAction({ ack, body, action, client }) {
}
}

async function openFileExplorer(client, triggerId, { channelId, userId, threadTs = "", file = "" } = {}) {
async function openFileExplorer(client, triggerId, { channelId, userId, threadTs = "", file = "", verifyMembership = false } = {}) {
if (!(await getChannelEntry(channelId))) {
await ensureRegistered(client, {
channel: channelId,
Expand All @@ -379,7 +380,7 @@ async function openFileExplorer(client, triggerId, { channelId, userId, threadTs
});
}
await ensureUserKnown(client, userId);
const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId, userId });
const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId, userId, verifyMembership });
const relativeFile = normalizeRelativePath(file || "");
const parent = relativeFile ? path.posix.dirname(relativeFile) : "";
const state = { channelId, slug: entry.slug, threadTs, ownerId: userId, relative: parent === "." ? "" : parent, page: 0 };
Expand Down Expand Up @@ -429,9 +430,9 @@ export async function secretScopeLists(meta, viewerId) {
};
}

async function openSecretsManager(client, triggerId, { channelId, userId, threadTs = "" } = {}) {
async function openSecretsManager(client, triggerId, { channelId, userId, threadTs = "", verifyMembership = false } = {}) {
await ensureUserKnown(client, userId);
const { entry, meta, mayEdit, userIsAdmin } = await secretsContext(client, { channelId, userId });
const { entry, meta, mayEdit, userIsAdmin } = await secretsContext(client, { channelId, userId, verifyMembership });
const state = { channelId, slug: entry.slug, threadTs, ownerId: userId };
await client.views.open({
trigger_id: triggerId,
Expand All @@ -457,8 +458,7 @@ export async function handleSecretsAction({ ack, body, action, client }, { conte
const command = parseSecretActionValue(action?.value);
try {
if (command.o === "open") {
if (!canOpenMenuButton({ ownerId: command.u, clickerId: clicker }) || !body?.trigger_id) throw new Error("This secrets button isn't for you.");
await openSecretsManager(client, body.trigger_id, { channelId: command.c, userId: clicker, threadTs: command.t || "" });
await openChannelMenuControl({ body, command, client, kind: "variables" });
return;
}
const state = parseSecretsMetadata(body?.view?.private_metadata);
Expand Down Expand Up @@ -1981,17 +1981,32 @@ async function notifyAdminsScopes(client, missing) {
await logEvent("scope_check_notified", { admins: sent, missing: missing.join(",") });
}

// A reply footer belongs to the requester who caused that reply, but gateway admins must still
// be able to inspect its workspace files. Everyone else remains bound to their own controls.
// A reply footer belongs to the requester who caused that reply, but current gateway admins
// can open every shared menu control. Everyone else remains bound to their own controls.
// An automation post's menu is unbound (no owner): it opens for whoever clicks, under that
// clicker's own live authorization (footer.js explains the binding).
export function canOpenMessageFileButton({ ownerId = "", clickerId = "", clickerIsAdmin = false } = {}) {
return canOpenMenuButton({ ownerId, clickerId, clickerIsAdmin });
}

export function canOpenMenuButton({ ownerId = "", clickerId = "", clickerIsAdmin = false } = {}) {
return Boolean(clickerId && (!ownerId || clickerId === ownerId || clickerIsAdmin));
}

// The same rule for the other menu buttons, which have no admin override.
export function canOpenMenuButton({ ownerId = "", clickerId = "" } = {}) {
return Boolean(clickerId && (!ownerId || clickerId === ownerId));
// Always open a new view as the actual clicker. Admin access to someone else's shared button
// must never turn its original requester into the owner of personal settings or credentials.
export async function openChannelMenuControl({ body, command, client, kind }) {
const clicker = body?.user?.id;
const channelId = body?.channel?.id || body?.container?.channel_id;
const open = { files: openFileExplorer, variables: openSecretsManager, settings: openChannelSettings }[kind];
const clickerIsAdmin = clicker && command.u && command.u !== clicker ? await isAdmin(clicker) : false;
if (!open || !command.c || (channelId && command.c !== channelId) || !body?.trigger_id
|| !canOpenMenuButton({ ownerId: command.u, clickerId: clicker, clickerIsAdmin })) {
throw new Error("This menu button isn't for you. Open your own with `/menu`.");
}
// Recheck membership on every shared message button, just as on later modal interactions.
await open(client, body.trigger_id, { channelId: command.c, userId: clicker, threadTs: command.t || "",
file: command.o === "open_file" ? command.p || "" : "", verifyMembership: true });
}

// Slack does not infer the source thread for an ephemeral posted from a Block Kit action. Carry
Expand Down Expand Up @@ -2044,16 +2059,7 @@ async function connectAndWire(app) {
const command = parseFileActionValue(action?.value);
try {
if (command.o === "open" || command.o === "open_file") {
const clickerIsAdmin = clicker && command.u && command.u !== clicker ? await isAdmin(clicker) : false;
if (!canOpenMessageFileButton({ ownerId: command.u, clickerId: clicker, clickerIsAdmin }) || !body?.trigger_id) {
throw new Error("This file explorer button isn't for you.");
}
await openFileExplorer(client, body.trigger_id, {
channelId: command.c,
userId: clicker,
threadTs: command.t || "",
file: command.o === "open_file" ? command.p || "" : "",
});
await openChannelMenuControl({ body, command, client, kind: "files" });
return;
}
const state = parseExplorerMetadata(body?.view?.private_metadata);
Expand Down Expand Up @@ -2210,12 +2216,7 @@ async function connectAndWire(app) {
const actionId = String(action?.action_id || "");
try {
if (command.o === "open") {
if (!canOpenMenuButton({ ownerId: command.u, clickerId: clicker }) || !body?.trigger_id) throw new Error("This settings button isn't for you.");
await openChannelSettings(client, body.trigger_id, {
channelId: command.c,
userId: clicker,
threadTs: command.t || "",
});
await openChannelMenuControl({ body, command, client, kind: "settings" });
return;
}

Expand Down
Loading
Loading