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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<p align="center">
<img src="assets/banner.png" alt="Forge — open-source AI agent framework" width="100%">
</p>

# Forge — OpenClaw for Enterprise: A Secure, Portable AI Agent Runtime

<p align="center">
<a href="https://useforge.ai/docs/"><img src="https://img.shields.io/badge/Docs-useforge.ai-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/PtFa4z97Ag"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://github.com/initializ/forge/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-green?style=for-the-badge" alt="License: Apache 2.0"></a>
<a href="https://initializ.ai"><img src="https://img.shields.io/badge/Built%20by-initializ-blueviolet?style=for-the-badge" alt="Built by initializ"></a>
</p>

Build, run, and deploy AI agents from a single `SKILL.md` file.
Secure by default. Runs anywhere — local, container, cloud, air-gapped.

Expand Down
Binary file added assets/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 28 additions & 1 deletion docs/core-concepts/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions forge-cli/templates/init/slack-config.yaml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
75 changes: 62 additions & 13 deletions forge-plugins/channels/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -100,6 +145,7 @@ func (p *Plugin) resolveBotID() error {
}

p.botUserID = result.UserID
p.ownBotID = result.BotID
return nil
}

Expand Down Expand Up @@ -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
}

Expand Down
122 changes: 121 additions & 1 deletion forge-plugins/channels/slack/slack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Loading