A Slack app that brings OpenShell sandbox network-egress approvals into Slack. When a
sandboxed agent hits an egress denial, OpenShell's analysis proposes a draft network-policy
change (a "chunk"). This bridge surfaces each pending chunk as an interactive Slack message so
an admin can approve or reject it. Approvals merge into that sandbox's policy and hot-reload;
its /wait loop then retries. Rejections leave the sandbox denied.
Scope is network egress only. Filesystem and process policy are locked at sandbox creation and are not proposable, so they are out of scope by design.
OpenShell gateway <--gRPC--> Bridge <--Socket Mode--> Slack
(draft chunks) (poll + decide) (admins)
- The bridge polls
GetDraftPolicyper sandbox (OpenShell never pushes new proposals). - A newly discovered pending chunk is posted to the routed channel as a Block Kit message.
- An admin clicks Approve (with a confirm) or Reject (opens a reason modal).
- The bridge calls
ApproveDraftChunk(with the chunk'sreview_token) orRejectDraftChunk, then rewrites the message to a terminal state noting who decided and the new policy version. - Decisions made outside Slack are detected on the next poll and the message is closed.
- Polling is mandatory. The
draft_policy_updatestream field exists in the proto but is never emitted server-side, so discovery is by pollingGetDraftPolicy. - Optimistic concurrency.
ApproveDraftChunkcarries areview_token; a stale token returnsFAILED_PRECONDITION. The bridge refreshes the token and retries once. - The bridge owns identity and message state. OpenShell records no approver identity on the
wire, so the bridge stores the Slack approver and the message coordinates in a durable state file
keyed by
chunk_id(STATE_STORE_PATH). This also survives restarts (startup reconciliation). - Single in-flight decision. A per-chunk lock prevents two admins from double-deciding.
- GA Block Kit only. No card/carousel primitives.
Create the app from manifest.json: api.slack.com/apps
-> Create New App -> From a manifest. It is a Socket Mode app (no request URLs) and requests a
single bot scope, chat:write, to keep the install easy for admins to approve. It also sets the App
Home + app_home_opened subscription and interactivity.
After creating it, install to the workspace for the bot token (xoxb-...) and generate an
app-level token with connections:write (xapp-...) for Socket Mode. The bot does not self-join
channels (that would need channels:join), so create the approval channel, invite the bot with
/invite @openshell_admin, and put the channel ID in config/admins.yaml. Full steps, including
connecting to a real gateway, are in docs/03-deployment.md.
cp .env.example .env
cp config/admins.example.yaml config/admins.yamlEdit .env (Slack tokens, gateway URL, auth mode) and config/admins.yaml (admin allow-list and
channel routing). Never commit .env or config/admins.yaml.
Auth modes:
mtls(single-host default): pointOPENSHELL_CA_CERT/OPENSHELL_CLIENT_CERT/OPENSHELL_CLIENT_KEYat the install bundle under~/.config/openshell/gateways/openshell/mtls/.bearer(Docker/Helm/K8s): setOPENSHELL_BEARER_TOKENorOPENSHELL_BEARER_TOKEN_FILE.
npm install
npm run dev # watch mode against a real gateway
npm start # compiled (after npm run build)The mock implements the OpenShell RPC subset in memory (seeded sandbox + pending chunks, honoring
review_token).
# Terminal 1: mock gateway (insecure loopback)
npm run dev:mock
# Terminal 2: bridge pointed at the mock
# set these in .env first:
# OPENSHELL_GATEWAY_URL=127.0.0.1:17670
# OPENSHELL_USE_TLS=false
npm run devThe mock seeds two pending chunks and injects a late proposal ~12s after start so you can watch
the poller pick it up. scripts/run-mock.ts is a variant that seeds a single pending chunk with no
late injection, useful for driving one card at a time.
Alongside the approval bridge, an optional audit sink streams OpenShell's OCSF audit
events into a private Slack channel as a searchable firehose. It runs as a separate process
(npm run start:capture, or npm run dev:capture in watch mode), independent of the
approve/reject bridge: it shares only config loading, never touches the gRPC decision path, and
its only outward call is chat.postMessage to a dedicated audit channel. By design it does no
dedup, so an approval-outcome event that also shows up here is acceptable.
Enable it by setting CAPTURE_SOURCES (comma-separated; empty = off) and a
routing.audit_channel in the YAML that differs from every approval channel. Invite the bot to
that channel (it holds only chat:write and cannot self-join).
file- tail the OCSF JSONL file OpenShell writes locally (the native path). SetCAPTURE_FILE_PATH(a glob is fine for daily-rotated logs); byte offsets persist toCAPTURE_FILE_OFFSET_STATEso a restart resumes without replaying the backlog.http- run an inbound receiver for an external log-shipper (Filebeat, Vector, Fluent Bit) to POST to. SetCAPTURE_RECEIVER_TOKEN(a shared bearer the receiver requires) and optionallyCAPTURE_RECEIVER_BIND(default0.0.0.0:8090). The body is NDJSON of bare OCSF objects by default; a JSON array or a CloudEvents envelope is also accepted. SetCAPTURE_RECEIVER_TLS_CERT+CAPTURE_RECEIVER_TLS_KEYto serve HTTPS.
To silence specific noise, set capture.exclude_event_types in the YAML (exact,
case-insensitive match on the OCSF class or type name); the default captures everything. Posts
flow through a bounded, rate-limited queue that applies backpressure (HTTP 503 + Retry-After on
the receiver) rather than dropping under load. See
docs/04-testing-runbook.md Step 9 for an agent-runnable capture test.
The bridge also publishes an App Home tab for admins, refreshed on app_home_opened: the
pending-approval queue rendered as action cards plus a native chart of recent decision activity,
with cards deep-linking to the original request message. The Home view uses newer Block Kit
primitives (an action-card carousel and data_visualization charts); if views.publish rejects
them, the bridge retries once with a GA-only fallback so the tab is never left blank. This is
part of the approval bridge (src/index.ts -> src/app-home.ts), not the audit sink.
npm test # unit + a real-socket gRPC integration test against the mock
npm run typecheckFor a full end-to-end, agent-runnable integration runbook (install OpenShell -> produce a real pending chunk -> decide -> capture an audit event), see docs/04-testing-runbook.md.
| Path | Responsibility |
|---|---|
src/config.ts |
Env + YAML config, admin allow-list, role gating, channel routing |
src/openshell-client.ts |
gRPC client, wire types, mTLS/bearer credentials |
src/state-store.ts |
Durable chunk_id-keyed store (coords, review_token, lock, terminal status) |
src/action-request.ts |
Normalizes a PolicyChunk into a display-ready request |
src/slack-messages.ts |
GA Block Kit builders and the reject modal |
src/app-home.ts |
App Home dashboard view |
src/poller.ts |
Discovery loop; emits chunk_new / chunk_closed |
src/index.ts |
Bolt wiring, decision handlers, startup reconciliation |
src/capture-main.ts |
Audit sink entrypoint: standalone, post-only capture process (start:capture) |
src/capture/ |
OCSF ingest (file tail + HTTP receiver), normalize/filter/render, bounded send queue |
src/mock-server.ts |
In-memory OpenShell gateway for local testing |
proto/ |
OpenShell protobuf definitions |
This sample is released under the MIT License.
The Protocol Buffer definitions under proto/ are part of NVIDIA's
OpenShell project and are licensed under
Apache-2.0, not MIT. See proto/NOTICE for details.
