No description
  • TypeScript 99.6%
  • Shell 0.3%
  • Dockerfile 0.1%
Find a file
Michael Sadil 89444dd7f3
All checks were successful
validation / validate-release (push) Successful in 33s
chore(release): bump package version
Update the package and lockfile metadata from version 1.0.0 to 3.1.0 for the next Sentinel release.
2026-07-10 14:44:05 +02:00
.forgejo/workflows ci: validate and package release inputs 2026-07-10 14:25:14 +02:00
src feat(commands): configure first-message bans 2026-07-10 14:41:32 +02:00
tests feat(commands): configure first-message bans 2026-07-10 14:41:32 +02:00
.dockerignore build(release): package and validate Node 24 runtime 2026-07-10 14:25:14 +02:00
.gitignore build(release): package and validate Node 24 runtime 2026-07-10 14:25:14 +02:00
AGENTS.md docs: update agent guidance for durable runtime 2026-07-10 14:25:14 +02:00
config.example.json docs: document Sentinel V3 phase 1 setup 2026-07-05 22:49:54 +02:00
Design Spec.md docs: document first-message enforcement 2026-07-10 14:41:37 +02:00
Dockerfile build(release): package and validate Node 24 runtime 2026-07-10 14:25:14 +02:00
package-lock.json chore(release): bump package version 2026-07-10 14:44:05 +02:00
package.json chore(release): bump package version 2026-07-10 14:44:05 +02:00
README.md docs: document first-message enforcement 2026-07-10 14:41:37 +02:00
tsconfig.json build(release): package and validate Node 24 runtime 2026-07-10 14:25:14 +02:00
tsconfig.test.json build(release): package and validate Node 24 runtime 2026-07-10 14:25:14 +02:00
validate-docker.sh build(release): package and validate Node 24 runtime 2026-07-10 14:25:14 +02:00
vitest.config.ts feat(process): supervise startup and shutdown 2026-07-10 14:25:14 +02:00

Sentinel

Sentinel is a multi-server Discord anti-spam honeypot bot. It gives eligible members a harmless, mentionable role and treats a ping of that role as the moderation signal. The default role name is Do not ping, so Discord renders the warning as @Do not ping when someone attempts to mention it.

Sentinel V3 is deliberately narrow. It is not a general spam classifier or moderation suite.

Features

  • Per-guild desired configuration, observed runtime health, and revision tracking.
  • A dedicated, no-permission trap role with ownership and hierarchy checks.
  • Automatic assignment on join, background retroassignment, and role reapplication.
  • Unified threshold and rolling-window behavior in message and AutoMod modes.
  • Durable SQLite detection evidence, enforcement claims, and retry outcomes.
  • Strictly exclusive AutoMod rule validation to prevent unrelated triggers from causing bans.
  • Optional bounded DM delivery and triggering-message deletion before a ban.
  • Per-guild exemptions, structured logs, and a genuinely allowlisted safe export.
  • Transactional SQLite schema migrations and one-time legacy config.json import.
  • Graceful, deadline-bounded shutdown on SIGINT and SIGTERM.
  • A verified release archive and a non-root Node 24 OCI deployment.

Protection State

Sentinel separates operator intent from observed protection health:

  • desired_enabled records whether an operator wants protection enabled.
  • config_revision identifies the latest desired configuration.
  • validated_revision identifies the revision whose Discord resources were last verified.
  • policy_epoch changes when detection-sensitive policy changes, preventing old evidence from being enforced under a new policy.
  • The runtime controller reports disabled, reconciling, ready, degraded, or stopping.

A settings change closes detection intake before it is persisted. Sentinel then reconciles the log channel, trap role, permissions, and selected detector. Detection resumes only when the controller is ready and validated_revision equals config_revision. A valid desired change can remain saved while reconciliation is degraded; /sentinel status shows the revision gap and latest error.

This fail-closed model is per guild. A failing guild does not stop other guilds from initializing or processing events.

Detection Modes

Both detection modes write to the same SQLite evidence ledger and use the same ban_threshold and ban_window_seconds contract. Reaching the threshold atomically creates at most one open enforcement claim for a user, guild, and policy epoch. The optional first-message ban treats a member's first human message observed by Sentinel as an immediate threshold when it contains the trap ping in message mode or triggers the managed rule in AutoMod mode.

Changing modes applies mode-aware defaults:

Mode Default threshold Default window
automod 3 pings 3600 seconds
message 1 ping 30 seconds

AutoMod

automod is the default and recommended mode. Discord blocks the message before it appears, and Sentinel counts only attributable BLOCK_MESSAGE executions from the current verified rule.

Managed mode uses a dedicated Sentinel-owned keyword rule. A healthy rule must have all of these properties:

  • It belongs to the current guild and is enabled.
  • It uses the MESSAGE_SEND event and KEYWORD trigger.
  • Its only trigger is the exact current trap-role mention regex.
  • It has no keyword filters, presets, allow-list entries, exempt roles, or exempt channels.
  • It has exactly one block action and one alert action targeting the current log channel.

Sentinel does not merge its trigger into a shared rule. Verify-only adoption requires an explicit rule ID with the same exclusive shape; Sentinel validates but never edits or disables that external rule. Consequently, an external rule can continue blocking after Sentinel is disabled or switched to message mode, and Sentinel reports that residual behavior for manual cleanup. Switching from an adopted rule to managed mode preserves a cleanup record for the external rule while creating a new dedicated rule.

AutoMod events without a Discord message ID have no stable deduplication key. They are handled with at-least-once semantics, so a duplicate gateway delivery can count again. The exclusive rule shape limits the signal to trap-role pings but cannot manufacture a missing Discord event identifier.

Message

message mode processes messageCreate and messageUpdate. Partial updated messages are fetched before evaluation. Sentinel persists message-version state so duplicate delivery is ignored while an edit that newly introduces the trap-role mention records evidence.

If Discord cannot provide the prior version and no durable transition state exists, a complete fetched current message containing the trap role is treated as new evidence. This fail-safe prevents an uncached edit bypass, but an unrelated edit retaining a pre-existing mention can count once after transition state has expired or the trap-role identity changed.

Message mode and AutoMod mode use the same rolling threshold ledger. When a message-mode claim is enforced, deletion of the contributing messages is attempted with a deadline; Discord's configured historical deletion window is also requested on the ban.

Requirements

  • Node.js 24 LTS and npm 11 for archive deployments.
  • A Discord application and bot user.
  • Persistent local storage for SQLite.
  • Ban Members and Manage Roles in every enabled guild.
  • Manage Server when AutoMod mode is used.
  • View Channel and Send Messages in the configured log channel.
  • The bot's highest role above the trap role.

The process always requests these gateway intents: Guilds, Guild Members, Guild Messages, Message Content, Auto Moderation Configuration, and Auto Moderation Execution. Therefore both the privileged Server Members Intent and Message Content Intent must be enabled in the Discord Developer Portal for every deployment, including deployments that currently use only AutoMod mode.

Installation

Install, validate, and build with the supported runtime:

npm ci
npm run typecheck
npm run typecheck:test
npm test
npm run test:coverage
npm run build
npm run smoke

npm run smoke is offline. It loads the built native SQLite dependency, migrates a temporary database, and constructs the Discord client options without requiring a token, gateway login, or network request.

For local development without building first:

npm run dev

Process Configuration

Sentinel loads .env from the current working directory. Existing environment variables take precedence.

DISCORD_TOKEN=your-bot-token
SENTINEL_DB_PATH=/srv/sentinel/data/sentinel.sqlite
Variable Description Default
DISCORD_TOKEN Discord bot token. SENTINEL_BOT_TOKEN remains a compatibility alias. Required
SENTINEL_APPLICATION_ID Discord application ID used for command registration. Derived after login
SENTINEL_DB_PATH SQLite database path. Empty paths and :memory: are rejected in production. sentinel.sqlite
SENTINEL_COMMAND_REGISTRATION global, guild, or none. global, or guild when guild IDs are supplied
SENTINEL_COMMAND_GUILD_IDS Comma-separated guild IDs for development registration. Empty

The Discord token is process-only configuration. It is not stored in SQLite or included in exports.

Server Setup

Run /sentinel setup inside each server. All /sentinel operations require Manage Server or Administrator; trap-role create, adopt, and rename operations additionally enforce Manage Roles and actor role hierarchy.

Recommended order:

  1. Set a guild text or announcement log channel.
  2. Keep automod unless message-mode behavior is specifically required.
  3. Create or adopt a dedicated no-permission trap role.
  4. Configure thresholds and the optional ban DM.
  5. Add trusted users to the exemption list.
  6. Run validation and enable protection.
  7. Start retroassignment for existing members if desired.
Command Purpose
/sentinel setup Open the revision-aware setup dashboard.
/sentinel status Show desired state, observed state, revisions, resources, threshold, and latest error.
/sentinel validate Reconcile and verify current permissions and resources.
/sentinel enable Save enabled intent, reconcile resources, and activate only if healthy.
/sentinel disable Stop enforcement and disable a Sentinel-owned AutoMod rule.
`/sentinel mode <automod message>`
/sentinel log-channel <channel> Set and validate the log channel.
/sentinel thresholds <count> <window_seconds> Set the unified rolling threshold.
/sentinel first-message-ban <enabled> Toggle immediate bans for first observed messages that trigger Sentinel.
`/sentinel trap-role create adopt
`/sentinel automod manage adopt
/sentinel dm configure Configure the bounded pre-ban DM.
`/sentinel exemptions add remove
/sentinel retroassign Start bounded background assignment for existing eligible members.
/sentinel export-config Attach the allowlisted safe configuration export.

Setup component IDs include the configuration revision. Submissions from an older dashboard are rejected rather than overwriting newer settings.

Safe Exports

/sentinel export-config returns an ephemeral sentinel-safe-config-v1.json attachment built from an allowlist. It omits the ban DM text, appeal invite, exempt user IDs, process secrets, and internal timestamps.

The safe export still contains guild and Discord resource IDs plus reconciliation state. Treat it as operational information, not public content. It is not a database backup and cannot restore detection or enforcement state.

SQLite Operations

Layout And Permissions

Use a persistent path such as /data/sentinel.sqlite in containers or /srv/sentinel/data/sentinel.sqlite on a host. On POSIX systems Sentinel creates the parent directory with mode 0700 and hardens the database file to 0600 on a best-effort basis. Verify ownership and modes in deployment automation, especially for network filesystems and bind mounts.

SQLite runs with foreign keys, a busy timeout, and WAL journaling. The database, -wal, and -shm files are one logical data set while the process is running. Never copy only the main database file from a live WAL database.

Migrations

Schema migrations use SQLite PRAGMA user_version; the current schema is version 4. Migrations run on startup inside immediate transactions, reject a database newer than the binary, and refuse unknown unversioned layouts. Invalid legacy rows are quarantined instead of being trusted as active protection. Back up the database before every binary upgrade and test the migration against a restored production-like copy.

Backup

For an online WAL-consistent backup, use the SQLite backup API through the sqlite3 CLI on the same host:

DB=/srv/sentinel/data/sentinel.sqlite
BACKUP=/srv/sentinel/backups/sentinel-$(date -u +%Y%m%dT%H%M%SZ).sqlite
install -d -m 0700 /srv/sentinel/backups
sqlite3 "$DB" ".backup '$BACKUP'"
chmod 0600 "$BACKUP"
sqlite3 "$BACKUP" "PRAGMA integrity_check;"

Alternatively, stop Sentinel gracefully, wait for shutdown to complete, and then copy the closed database. Normal close checkpoints and truncates the WAL. If shutdown did not complete cleanly, preserve the main, -wal, and -shm files together rather than guessing which sidecars are stale.

Restore

  1. Stop Sentinel and retain the existing data directory for rollback.
  2. Run PRAGMA integrity_check on the backup.
  3. Install the restored file with the service UID/GID, directory mode 0700, and file mode 0600.
  4. Remove old WAL/SHM sidecars only while Sentinel is stopped and only after preserving the old data set.
  5. Start the same version that created the backup, or a newer version whose migration has been tested.
  6. Confirm the logged schema version and inspect every guild's observed state and revision.

Retention

  • Active guild configuration and exemptions are retained until explicitly changed or the guild record is purged.
  • Open detection evidence and message-version state expire with the configured detection window and are pruned lazily as new detections are recorded.
  • Sentinel runs retention cleanup at startup and every 24 hours. Closed/manual enforcement audit and departed-guild configuration are retained for 30 days before cascading removal.
  • Monitor database size and free space. Do not delete rows manually from a live database; use the built-in retention cycle or a tested offline maintenance procedure.
  • Backup retention is operator policy. A practical baseline is seven encrypted daily backups and four weekly backups, with periodic restore tests and access restricted to operators who can read guild configuration.

Graceful Shutdown

On SIGINT or SIGTERM, Sentinel stops gateway and interaction intake, closes every guild controller, cancels queued assignment and retry work, and uses one 10-second deadline for active dispatch, reconciliation, assignment, enforcement, client destruction, and log flushing. Shutdown is idempotent. SQLite is checkpointed and closed only after application work drains; if the deadline expires it remains open for late durable completions and the process receives a failing exit code.

Process managers and containers should send SIGTERM and allow at least 20 seconds before SIGKILL. Do not terminate the process immediately during migrations or backup preparation.

Health And Readiness

Sentinel has no HTTP health endpoint. Use these signals:

  • Process liveness: the Node process remains running under a supervisor.
  • Artifact health: npm run smoke succeeds offline.
  • Gateway readiness: startup logs report Sentinel ready in N guild(s).
  • Protection readiness: /sentinel status reports ready with equal validated/config revisions for that guild.
  • Degraded operation: structured stderr and the configured Discord log channel report reconciliation or enforcement failures.

The OCI image intentionally has no Docker HEALTHCHECK; an offline smoke probe cannot prove Discord gateway or per-guild resource readiness.

Deployment

Verified Release Archive

Forgejo CI uploads sentinel-release.tgz. It contains the deployable input set rather than dist/ alone:

  • the complete built dist/ tree, including entry points and shared chunks
  • package.json and package-lock.json
  • the production CycloneDX SBOM
  • Dockerfile and .dockerignore
  • this README and the architecture specification

The archive intentionally omits node_modules. Install on the target Node 24 platform so better-sqlite3 receives the correct architecture/libc native binary:

tar -xzf sentinel-release.tgz -C /opt/sentinel
cd /opt/sentinel
npm ci --omit=dev
npm run smoke
DISCORD_TOKEN=... SENTINEL_DB_PATH=/srv/sentinel/data/sentinel.sqlite npm start

Run Sentinel under a process supervisor with restart-on-failure, persistent data, secret injection, log capture, and a shutdown grace period.

OCI Image

The Dockerfile consumes the built release inputs, installs production dependencies, runs the offline smoke during the image build, and runs as the upstream node user (UID/GID 1000). /data is the persistent volume and the default database path is /data/sentinel.sqlite.

From a source checkout:

npm ci
npm run build
docker build -t sentinel:local .
docker volume create sentinel-data
docker run -d --name sentinel \
  --restart unless-stopped \
  --stop-timeout 20 \
  --env-file /srv/sentinel/sentinel.env \
  --mount source=sentinel-data,target=/data \
  sentinel:local

The same docker build works after extracting the verified release archive. For a host bind mount, pre-create the directory as UID/GID 1000 with mode 0700; do not make the database world-readable to work around ownership errors.

CI And Supply Chain

.forgejo/workflows/push.yml validates pushes and pull requests targeting main or release/*. It uses read-only repository permissions, per-ref concurrency cancellation, a 30-minute timeout, immutable Forgejo action SHAs, and an immutable Node 24 OCI digest.

The release job runs clean install, source typecheck, test/config typecheck, tests, coverage gates, build, production audit, a high-severity audit of the full development tree, CycloneDX SBOM generation, offline smoke, a clean production-only staging install, a second staged smoke, and archive upload.

npm audit --omit=dev is blocking at every severity, and the full tree blocks high/critical advisories. Dependency exceptions must not be implemented by ignoring CI failures; record the advisory, affected path, runtime exposure, mitigation, owner, expiry, and removal condition in a reviewed issue. The temporary undici override remains until the Discord dependency chain ships a safe compatible version and must be retested before removal. Action SHAs and the Node digest are refreshed only through deliberate dependency maintenance.

Forgejo Branch Protection

Branch protection is external repository configuration. Workflow YAML can publish a check but cannot prevent an administrator or unprotected branch from merging.

After the first pull request run, configure Forgejo branch rules for main and protected release/* branches to:

  1. Require pull requests and successful status checks before merge.
  2. Select the validate-release job from the validation workflow. Some Forgejo versions display it as validation / validate-release; select the exact emitted check.
  3. Require the branch to be up to date before merge if the repository's merge policy supports it.
  4. Restrict force pushes, deletion, direct pushes, and bypass permissions to the smallest operator set.

Legacy Configuration

config.json is deprecated and import-only. On startup Sentinel fingerprints it, records import status, and imports an unchanged file at most once. Imported settings remain pending reconciliation rather than being trusted as ready. DISCORD_TOKEN is still required in the environment. After verifying the imported guild and token migration, securely delete config.json; it may contain the old bot token.

Operational Notes

  • Sentinel-owned resources may be repaired. External trap roles and verify-only AutoMod rules are read-only.
  • The trap role must be mentionable, editable, below the bot, and have zero permissions.
  • New members are assigned after a randomized 1-3 second delay.
  • Assignment work is bounded per guild, prioritizes live joins over bulk retroassignment, and rechecks revision and exemptions at execution time.
  • A deleted owned role or rule closes intake and triggers full reconciliation. External resource deletion degrades protection until manually replaced.
  • Ban claims and attempts are durable. Retryable work can recover after restart; exhausted or permanent failures remain visible as manual-action state.
  • Discord AutoMod can keep blocking while Sentinel is offline, but Sentinel cannot receive or backfill missed gateway events.
  • Structured stdout/stderr is the primary log sink. Discord log delivery is secondary, suppresses mentions, and is flushed best-effort on shutdown.

Development

npm run typecheck
npm run typecheck:test
npm test
npm run test:coverage
npm run build
npm run audit:production
npm run sbom
npm run smoke
Path Purpose
src/process-supervisor.ts Process configuration, client construction, startup, and shutdown.
src/runtime.ts Per-guild controller, revision gates, dispatch, and orchestration.
src/resource-reconciler.ts Permission and Discord resource postcondition checks.
src/store.ts Schema migrations, settings, evidence, claims, attempts, and legacy import.
src/detection-service.ts Shared threshold ledger boundary for both detectors.
src/monitor.ts Message create/update adapter.
src/automod-monitor.ts Verified AutoMod execution adapter.
src/ban.ts Idempotent claim enforcement, bounded side effects, and retry outcomes.
src/assignment-worker.ts Bounded priority role-assignment scheduling.
src/interactions.ts Slash command authorization and responses.
src/setup-flow.ts Revision-aware interactive setup.
src/smoke.ts Built, offline release smoke entry point.
tests/ Vitest unit, persistence, contract, and lifecycle tests.

Troubleshooting

Symptom Check
Slash commands do not appear Registration mode, application ID, guild IDs, and global propagation delay.
Startup rejects privileged intents Enable both Server Members and Message Content in the Developer Portal.
Status remains reconciling or degraded Revision gap and latest reconciliation error in /sentinel status; structured logs contain the failing resource.
Missing Manage Roles Grant the permission and move the bot above the trap role.
AutoMod rule is rejected Use an exclusive keyword rule with only the exact trap-role regex, block action, and alert action.
Disable still blocks pings A verify-only/external AutoMod rule remains active; disable it manually in Discord.
SQLite is read-only Verify data directory ownership, mode 0700, file mode 0600, and container UID/GID 1000.
OCI build cannot find dist Run npm run build first or build from the extracted CI release archive.

License

MIT