diff --git a/README.md b/README.md
index 4c59e3b..62c72e3 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,16 @@
+
+
+
+
# Forge — OpenClaw for Enterprise: A Secure, Portable AI Agent Runtime
+
+
+
+
+
+
+
Build, run, and deploy AI agents from a single `SKILL.md` file.
Secure by default. Runs anywhere — local, container, cloud, air-gapped.
diff --git a/assets/banner.png b/assets/banner.png
new file mode 100644
index 0000000..1c3061d
Binary files /dev/null and b/assets/banner.png differ
diff --git a/docs/core-concepts/channels.md b/docs/core-concepts/channels.md
index 218bc75..9b82e3e 100644
--- a/docs/core-concepts/channels.md
+++ b/docs/core-concepts/channels.md
@@ -88,13 +88,34 @@ Before running the Slack adapter, create and configure a Slack App:
### Mention-Aware Filtering
-The Slack adapter resolves the bot's own user ID at startup via `auth.test` and uses it for intelligent message filtering:
+The Slack adapter resolves the bot's own user ID **and** `bot_id` at startup via `auth.test`. The user ID drives @mention matching; the `bot_id` powers the self-loop guard.
- **Channel messages** — the bot only responds when explicitly @mentioned (e.g. `@ForgeBot what's the status?`)
- **Thread replies** — the bot responds to all messages in a thread it's participating in, unless the message @mentions a different user
- **Direct messages** — all DMs are processed
- Bot mentions are stripped from the message text before passing to the LLM, so it sees clean input
+### Bot Authorship Admission
+
+By default the adapter ignores every event whose Slack `bot_id` is non-empty — this prevents bot-to-bot loops. Operators can admit specific bots (scheduler, monitoring tool, CI bot) that should be allowed to @-mention the agent by listing their `bot_id`s in `slack-config.yaml`:
+
+```yaml
+adapter: slack
+settings:
+ app_token_env: SLACK_APP_TOKEN
+ bot_token_env: SLACK_BOT_TOKEN
+ allow_bot_ids: B0123ABC,B0456DEF
+```
+
+Two safeguards keep loops bounded:
+
+| Rule | Scope |
+|---|---|
+| **Self-loop guard** | The agent's own `bot_id` is always dropped, even if listed in `allow_bot_ids`. No opt-out. |
+| **Mention requirement** | Admitted bots still must include `<@FORGE_AGENT_USER_ID>` in the message text — chatter from an allowed bot without an @-mention is ignored. |
+
+Both drop paths emit an operator-actionable log line naming the `bot_id` and pointing at the YAML setting, so debugging is self-service. Find a bot's `bot_id`: Slack admin → Manage apps → app → Bot User OAuth.
+
### Processing Indicators
When the Slack adapter receives a message:
@@ -124,12 +145,18 @@ adapter: slack
settings:
app_token_env: SLACK_APP_TOKEN
bot_token_env: SLACK_BOT_TOKEN
+ # Optional: comma-separated bot_ids whose @mentions are admitted.
+ # Default (omit / empty) = no other bots admitted; only humans trigger.
+ # The agent's own bot_id is always dropped, regardless of this list.
+ # allow_bot_ids: B0123ABC,B0456DEF
```
Environment variables:
- `SLACK_APP_TOKEN` — Socket Mode app-level token (`xapp-...`)
- `SLACK_BOT_TOKEN` — Bot user OAuth token (`xoxb-...`)
+See [Bot Authorship Admission](#bot-authorship-admission) for `allow_bot_ids` details.
+
### Telegram (`telegram-config.yaml`)
```yaml
diff --git a/forge-cli/templates/init/slack-config.yaml.tmpl b/forge-cli/templates/init/slack-config.yaml.tmpl
index 620196f..da8da1a 100644
--- a/forge-cli/templates/init/slack-config.yaml.tmpl
+++ b/forge-cli/templates/init/slack-config.yaml.tmpl
@@ -2,3 +2,12 @@ adapter: slack
settings:
app_token_env: SLACK_APP_TOKEN
bot_token_env: SLACK_BOT_TOKEN
+
+ # Optional: comma-separated list of bot_ids whose @mentions of this agent
+ # should be honored. By default no other bots are admitted, so the agent
+ # only responds to humans. Use this to let a scheduler, monitoring tool,
+ # or CI bot mention the agent. The agent's own bot_id is always dropped
+ # regardless of this list (self-loop guard).
+ #
+ # Find a bot's bot_id: Slack admin → Manage apps → app → Bot User OAuth.
+ # allow_bot_ids: B0123ABC,B0456DEF
diff --git a/forge-plugins/channels/slack/slack.go b/forge-plugins/channels/slack/slack.go
index 9b476cc..f474383 100644
--- a/forge-plugins/channels/slack/slack.go
+++ b/forge-plugins/channels/slack/slack.go
@@ -29,16 +29,18 @@ const longRunningThreshold = 15 * time.Second
// Plugin implements channels.ChannelPlugin for Slack using Socket Mode.
type Plugin struct {
- appToken string
- botToken string
- botUserID string // resolved at startup via auth.test
- wsConn *websocket.Conn
- connMu sync.Mutex
- stopCh chan struct{}
- client *http.Client
- apiBase string // overridable for tests
- dedupMu sync.Mutex
- dedupCache map[string]time.Time
+ appToken string
+ botToken string
+ botUserID string // resolved at startup via auth.test
+ ownBotID string // resolved at startup via auth.test; used as the self-loop guard
+ allowBotIDs map[string]bool // bot_ids whose @mentions are admitted; default empty (no other bots admitted)
+ wsConn *websocket.Conn
+ connMu sync.Mutex
+ stopCh chan struct{}
+ client *http.Client
+ apiBase string // overridable for tests
+ dedupMu sync.Mutex
+ dedupCache map[string]time.Time
}
// New creates an uninitialised Slack plugin.
@@ -64,10 +66,52 @@ func (p *Plugin) Init(cfg channels.ChannelConfig) error {
return fmt.Errorf("slack: bot_token is required (set SLACK_BOT_TOKEN)")
}
+ // Optional: comma-separated list of bot_ids whose @mentions of the agent
+ // are admitted. Empty (the default) means no other bots are admitted —
+ // the agent only responds to humans. The agent's own bot_id is always
+ // dropped regardless of this list (see ownBotID guard in Start).
+ p.allowBotIDs = parseAllowBotIDs(settings["allow_bot_ids"])
+
return nil
}
-// resolveBotID calls auth.test to discover the bot's own Slack user ID.
+// parseAllowBotIDs splits a comma-separated bot_id list into a lookup set.
+// Whitespace around entries is trimmed; empty entries are skipped.
+func parseAllowBotIDs(raw string) map[string]bool {
+ set := make(map[string]bool)
+ for _, id := range strings.Split(raw, ",") {
+ id = strings.TrimSpace(id)
+ if id != "" {
+ set[id] = true
+ }
+ }
+ return set
+}
+
+// admitBotEvent decides whether an inbound event authored by a bot should
+// flow through to the agent. Human messages (botID == "") always admit.
+// The agent's own bot_id is dropped unconditionally — this is the self-loop
+// guard, not subject to the allowlist. Any other bot is admitted only when
+// its bot_id appears in allowBotIDs.
+//
+// The returned reason string is the operator-facing log line for dropped
+// events; for admitted events it is empty.
+func (p *Plugin) admitBotEvent(botID string) (reason string, admit bool) {
+ if botID == "" {
+ return "", true
+ }
+ if botID == p.ownBotID {
+ return fmt.Sprintf("dropping event authored by self (bot_id=%s)", botID), false
+ }
+ if !p.allowBotIDs[botID] {
+ return fmt.Sprintf("dropping event from non-allowlisted bot (bot_id=%s); add to slack-config.yaml allow_bot_ids to admit", botID), false
+ }
+ return "", true
+}
+
+// resolveBotID calls auth.test to discover the bot's own Slack user ID and
+// bot_id. The user ID drives @mention matching; the bot_id powers the
+// self-loop guard that drops messages authored by this same bot.
func (p *Plugin) resolveBotID() error {
req, err := http.NewRequest(http.MethodPost, p.apiBase+"/auth.test", nil)
if err != nil {
@@ -90,6 +134,7 @@ func (p *Plugin) resolveBotID() error {
var result struct {
OK bool `json:"ok"`
UserID string `json:"user_id"`
+ BotID string `json:"bot_id"`
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(body, &result); err != nil {
@@ -100,6 +145,7 @@ func (p *Plugin) resolveBotID() error {
}
p.botUserID = result.UserID
+ p.ownBotID = result.BotID
return nil
}
@@ -325,8 +371,11 @@ func (p *Plugin) readLoop(ctx context.Context, conn *websocket.Conn, handler cha
continue
}
- // Skip bot messages.
- if payload.Event.BotID != "" {
+ // Bot-authored events go through admitBotEvent: self-mentions are
+ // always dropped (loop guard); other bots are dropped unless the
+ // operator has admitted them via allow_bot_ids in slack-config.yaml.
+ if reason, admit := p.admitBotEvent(payload.Event.BotID); !admit {
+ fmt.Printf(" slack: %s\n", reason)
continue
}
diff --git a/forge-plugins/channels/slack/slack_test.go b/forge-plugins/channels/slack/slack_test.go
index 7d11ee3..0a58722 100644
--- a/forge-plugins/channels/slack/slack_test.go
+++ b/forge-plugins/channels/slack/slack_test.go
@@ -467,7 +467,9 @@ func TestResolveBotID(t *testing.T) {
t.Errorf("Authorization = %q, want 'Bearer xoxb-test-token'", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
- w.Write([]byte(`{"ok":true,"user_id":"U123BOT"}`)) //nolint:errcheck
+ // auth.test returns both user_id and bot_id; the plugin needs both —
+ // user_id for @mention matching, bot_id for the self-loop guard.
+ w.Write([]byte(`{"ok":true,"user_id":"U123BOT","bot_id":"B123BOT"}`)) //nolint:errcheck
}))
defer srv.Close()
@@ -482,6 +484,9 @@ func TestResolveBotID(t *testing.T) {
if p.botUserID != "U123BOT" {
t.Errorf("botUserID = %q, want U123BOT", p.botUserID)
}
+ if p.ownBotID != "B123BOT" {
+ t.Errorf("ownBotID = %q, want B123BOT", p.ownBotID)
+ }
}
func TestResolveBotID_Error(t *testing.T) {
@@ -788,3 +793,118 @@ func TestEvictExpiredDedup(t *testing.T) {
t.Error("recent-env should still be present")
}
}
+
+// --- Bot-mention admission (issue #55) -----------------------------------
+
+func TestParseAllowBotIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ want []string // sorted list of ids expected in the set
+ }{
+ {"empty", "", nil},
+ {"single", "B0123ABC", []string{"B0123ABC"}},
+ {"two with spaces", " B0123ABC , B0456DEF ", []string{"B0123ABC", "B0456DEF"}},
+ {"empty entries ignored", "B0123ABC,,B0456DEF,", []string{"B0123ABC", "B0456DEF"}},
+ {"whitespace only ignored", " , , ", nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseAllowBotIDs(tt.raw)
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %d entries, want %d (entries=%v)", len(got), len(tt.want), got)
+ }
+ for _, id := range tt.want {
+ if !got[id] {
+ t.Errorf("missing %q in result", id)
+ }
+ }
+ })
+ }
+}
+
+func TestInit_PopulatesAllowBotIDs(t *testing.T) {
+ p := New()
+ err := p.Init(channels.ChannelConfig{
+ Adapter: "slack",
+ Settings: map[string]string{
+ "app_token": "xoxa-test",
+ "bot_token": "xoxb-test",
+ "allow_bot_ids": "B0123ABC, B0456DEF",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+ if !p.allowBotIDs["B0123ABC"] || !p.allowBotIDs["B0456DEF"] {
+ t.Errorf("allowBotIDs = %v, want both B0123ABC and B0456DEF admitted", p.allowBotIDs)
+ }
+}
+
+func TestInit_AllowBotIDsAbsent(t *testing.T) {
+ // Default behavior: with no allow_bot_ids setting, the allowlist is
+ // empty and only humans (botID == "") flow through admitBotEvent.
+ p := New()
+ err := p.Init(channels.ChannelConfig{
+ Adapter: "slack",
+ Settings: map[string]string{
+ "app_token": "xoxa-test",
+ "bot_token": "xoxb-test",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+ if len(p.allowBotIDs) != 0 {
+ t.Errorf("expected empty allowBotIDs, got %v", p.allowBotIDs)
+ }
+}
+
+func TestAdmitBotEvent(t *testing.T) {
+ p := New()
+ p.ownBotID = "B0SELF"
+ p.allowBotIDs = map[string]bool{"B0ALLOWED": true}
+
+ tests := []struct {
+ name string
+ botID string
+ wantAdmit bool
+ // wantReasonSubstr is checked when admit is false to make sure the
+ // log line is operator-actionable.
+ wantReasonSubstr string
+ }{
+ {"human message admitted", "", true, ""},
+ {"own bot_id dropped (self-loop guard)", "B0SELF", false, "authored by self"},
+ {"allowlisted bot admitted", "B0ALLOWED", true, ""},
+ {"non-allowlisted bot dropped", "B0OTHER", false, "non-allowlisted"},
+ {"non-allowlisted bot reason mentions allow_bot_ids", "B0OTHER", false, "allow_bot_ids"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reason, admit := p.admitBotEvent(tt.botID)
+ if admit != tt.wantAdmit {
+ t.Errorf("admit = %v, want %v (reason=%q)", admit, tt.wantAdmit, reason)
+ }
+ if !admit && !strings.Contains(reason, tt.wantReasonSubstr) {
+ t.Errorf("reason = %q, want substring %q", reason, tt.wantReasonSubstr)
+ }
+ })
+ }
+}
+
+// TestAdmitBotEvent_SelfGuardBeatsAllowlist verifies the hard rule from #55:
+// even if the agent's own bot_id were somehow listed in allow_bot_ids,
+// the self-loop guard short-circuits before the allowlist check.
+func TestAdmitBotEvent_SelfGuardBeatsAllowlist(t *testing.T) {
+ p := New()
+ p.ownBotID = "B0SELF"
+ p.allowBotIDs = map[string]bool{"B0SELF": true} // misconfiguration
+
+ reason, admit := p.admitBotEvent("B0SELF")
+ if admit {
+ t.Fatal("agent must never admit its own bot_id, even when allowlisted")
+ }
+ if !strings.Contains(reason, "self") {
+ t.Errorf("reason should identify self-loop guard, got %q", reason)
+ }
+}