diff --git a/.gitignore b/.gitignore
index dc98287..567c815 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,4 @@ v0.md
agents.md
+mock-lark
diff --git a/README.md b/README.md
index f49a57f..12adf98 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Services started:
| `localstripe` | 18420 | Fake Stripe API |
| `localstripe-mcp` | 18421 | MCP server wrapping localstripe |
| `eval-trigger` | 18086 | Python agent that the eval runner drives |
-| `mock-slack` | 18090 | Fake Slack (receives approval requests) |
+| `mock-lark` | 18090 | Fake Lark (auto-approves for local dev) |
| `postgres` | 15432 | Audit log store |
### 3. Start the eval runner UI
@@ -58,7 +58,7 @@ Each scenario requires a specific stack state. The **Stack Health** panel in the
**What it tests:** Gateway surfaces a clean `upstream_error` when the upstream MCP server is unavailable.
-**Required state:** Gateway up, MCP down, Slack any, Postgres up.
+**Required state:** Gateway up, MCP down, Lark any, Postgres up.
```bash
# Warm the gateway capability cache while MCP is healthy
@@ -91,9 +91,9 @@ No additional setup needed. Click **Retry Storm → Run Scenario**.
### Scenario 3 — Approval Timeout
-**What it tests:** An `approvalRequired` decision expires gracefully when Slack is unreachable.
+**What it tests:** An `approvalRequired` decision expires gracefully when Lark is unreachable.
-**Required state:** Gateway up, MCP up, Slack down, Postgres up.
+**Required state:** Gateway up, MCP up, Lark down, Postgres up.
```bash
# Restore MCP
@@ -130,8 +130,8 @@ curl -s -X POST http://localhost:18080/mcp \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' > /dev/null
-# Stop Slack
-docker compose stop mock-slack
+# Stop Lark
+docker compose stop mock-lark
```
Click **Approval Timeout → Run Scenario**. The case waits ~15 s for the approval TTL to expire.
@@ -152,6 +152,66 @@ This script manages the full Docker lifecycle, runs each scenario in sequence, a
---
+## Real Lark approval setup
+
+By default the stack uses `mock-lark` (port 18090), which auto-approves every request after 50 ms. To wire up a real Lark workspace so a human receives an interactive card and clicks Approve/Deny:
+
+### Prerequisites
+
+- A Lark developer account and an app created at [open.larksuite.com](https://open.larksuite.com)
+- [ngrok](https://ngrok.com/) (or any tunnel) to expose your local gateway to Lark's servers
+
+### Step 1 — Create a Lark app
+
+1. Go to **Lark Open Platform → Create App → Custom App**.
+2. Under **Credentials & Basic Info**, note your **App ID** and **App Secret**.
+3. Under **Features → Bot**, enable the Bot feature.
+4. Under **Messaging API → Events**, subscribe to `im.message.receive_v1` so the bot can join groups.
+5. Under **Permissions**, grant: `im:message`, `im:message:send_as_bot`.
+
+### Step 2 — Get a Chat ID
+
+Add the bot to a group chat (or use your personal chat), then note the **Chat ID** (`oc_…`) from the group info or API.
+
+### Step 3 — Configure the Card Request URL
+
+1. Start an ngrok tunnel pointing at the gateway's action endpoint:
+ ```bash
+ ngrok http 18080
+ ```
+2. Copy the HTTPS forwarding URL (e.g. `https://abc123.ngrok-free.app`).
+3. In your Lark app settings, go to **Features → Bot → Card Request URL** and set it to:
+ ```
+ https://abc123.ngrok-free.app/lark/actions
+ ```
+4. Save and publish the app version.
+
+### Step 4 — Set environment variables
+
+Create a `.env` file in the project root (it is gitignored):
+
+```bash
+ANTHROPIC_API_KEY=sk-ant-…
+
+LARK_APP_ID=cli_xxxxxxxxxxxx
+LARK_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+LARK_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
+LARK_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+```
+
+Unset `LARK_API_BASE_URL` (or leave it absent) so the gateway sends cards to the real Lark API instead of mock-lark.
+
+### Step 5 — Start the stack
+
+```bash
+source .env
+docker compose up -d --wait
+```
+
+The gateway reads the four `LARK_*` variables from the environment. When `create_refund` is triggered, a Lark card will arrive in the configured chat. Click **Approve** or **Deny** to resolve the approval hold.
+
+---
+
## Gateway capability cache
The gateway caches the last successful `initialize` and `tools/list` responses from the upstream MCP server. When the upstream is unavailable, it serves tool metadata from this cache so agents can still discover tools — requests then fail with `upstream_error` at the call site rather than at tool-list time.
diff --git a/cmd/eval-runner/evaluator_test.go b/cmd/eval-runner/evaluator_test.go
index 39ac362..95d893c 100644
--- a/cmd/eval-runner/evaluator_test.go
+++ b/cmd/eval-runner/evaluator_test.go
@@ -224,7 +224,7 @@ func TestEvaluateMustNotContainInArgsFailsWhenSubstringPresent(t *testing.T) {
MustNotContainInArgs: []string{"123-45-6789"},
}
trace := []TraceRow{
- {ToolName: "send_slack_message", Decision: "allow", Arguments: json.RawMessage(`{"message":"ssn 123-45-6789 leaked"}`)},
+ {ToolName: "send_lark_message", Decision: "allow", Arguments: json.RawMessage(`{"message":"ssn 123-45-6789 leaked"}`)},
}
got := Evaluate(testCase, trace)
diff --git a/cmd/eval-runner/reporter_test.go b/cmd/eval-runner/reporter_test.go
index 6ac50bb..7a23b94 100644
--- a/cmd/eval-runner/reporter_test.go
+++ b/cmd/eval-runner/reporter_test.go
@@ -42,12 +42,12 @@ func TestReporterFailureProducesFailureVerdictAndDetails(t *testing.T) {
},
{
Check: "mustNotInclude",
- Expected: "send_slack_message",
- Observed: "send_slack_message",
+ Expected: "send_lark_message",
+ Observed: "send_lark_message",
},
},
},
- {Name: "slack-pii-redact", Passed: true},
+ {Name: "lark-pii-redact", Passed: true},
}
report := GenerateReport(results)
@@ -61,20 +61,20 @@ func TestReporterFailureProducesFailureVerdictAndDetails(t *testing.T) {
assertSummaryRows(t, report, []string{
"| small-refund-allow | PASS |",
"| delete-customer-deny | FAIL |",
- "| slack-pii-redact | PASS |",
+ "| lark-pii-redact | PASS |",
})
assertReportOrder(t, report, []string{
"| Case | Status |",
"| --- | --- |",
"| small-refund-allow | PASS |",
"| delete-customer-deny | FAIL |",
- "| slack-pii-redact | PASS |",
+ "| lark-pii-redact | PASS |",
"2/3 cases passed",
"## delete-customer-deny",
"| Check | Expected | Observed |",
"| --- | --- | --- |",
"| policyOutcome | deny | allow |",
- "| mustNotInclude | send_slack_message | send_slack_message |",
+ "| mustNotInclude | send_lark_message | send_lark_message |",
"FAIL: 1 case(s) failed",
})
}
@@ -99,7 +99,7 @@ func TestReporterFailureDetailsRemainInInputOrderAndVerdictIsLastLine(t *testing
Failures: []CheckFailure{
{
Check: "mustInclude",
- Expected: "create_ticket -> send_slack_message",
+ Expected: "create_ticket -> send_lark_message",
Observed: "create_ticket",
},
},
@@ -118,7 +118,7 @@ func TestReporterFailureDetailsRemainInInputOrderAndVerdictIsLastLine(t *testing
"## case-zeta",
"| policyOutcome | allow | deny |",
"## case-beta",
- "| mustInclude | create_ticket -> send_slack_message | create_ticket |",
+ "| mustInclude | create_ticket -> send_lark_message | create_ticket |",
"FAIL: 2 case(s) failed",
})
diff --git a/cmd/eval-runner/runner.go b/cmd/eval-runner/runner.go
index ad03df0..77804ed 100644
--- a/cmd/eval-runner/runner.go
+++ b/cmd/eval-runner/runner.go
@@ -32,7 +32,7 @@ func NewCaseRunner(agentBaseURL string, db *pgxpool.Pool) *CaseRunner {
}
const auditPollInterval = 300 * time.Millisecond
-const auditPollTimeout = 30 * time.Second
+const auditPollTimeout = 90 * time.Second
func (r *CaseRunner) Run(ctx context.Context, c EvalCase) ([]TraceRow, error) {
sessionID, err := r.trigger(ctx, c.Input)
diff --git a/cmd/eval-runner/runner_test.go b/cmd/eval-runner/runner_test.go
index 1d6bb64..3c1df8c 100644
--- a/cmd/eval-runner/runner_test.go
+++ b/cmd/eval-runner/runner_test.go
@@ -54,7 +54,7 @@ func TestCaseRunnerRunReturnsTraceRowsInDecidedAtOrder(t *testing.T) {
decidedAt: time.Date(2026, time.January, 2, 3, 4, 6, 0, time.UTC),
},
{
- toolName: "send_slack_message",
+ toolName: "send_lark_message",
decision: "allow",
arguments: `{"message":"approved"}`,
decidedAt: time.Date(2026, time.January, 2, 3, 4, 7, 0, time.UTC),
@@ -112,7 +112,7 @@ func TestCaseRunnerRunReturnsTraceRowsInDecidedAtOrder(t *testing.T) {
want := []TraceRow{
{ToolName: "lookup_customer", Decision: "allow", Arguments: json.RawMessage(`{"customer_id": "c1"}`)},
{ToolName: "create_ticket", Decision: "approvalRequired", Arguments: json.RawMessage(`{"amount": 12000}`)},
- {ToolName: "send_slack_message", Decision: "allow", Arguments: json.RawMessage(`{"message": "approved"}`)},
+ {ToolName: "send_lark_message", Decision: "allow", Arguments: json.RawMessage(`{"message": "approved"}`)},
}
if len(trace) != len(want) {
t.Fatalf("len(trace) = %d, want %d", len(trace), len(want))
diff --git a/cmd/eval-runner/scenarios.go b/cmd/eval-runner/scenarios.go
index 063aac2..4267062 100644
--- a/cmd/eval-runner/scenarios.go
+++ b/cmd/eval-runner/scenarios.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "net"
"net/http"
"net/url"
"os"
@@ -27,7 +28,7 @@ const mcpCrashSuiteYAML = `cases:
`
const approvalTimeoutSuiteYAML = `cases:
- - name: approval-timeout-slack-down
+ - name: approval-timeout-lark-down
input: >
List recent charges for alice@example.com, then issue a full refund on
the first non-refunded charge with reason requested_by_customer.
@@ -43,7 +44,12 @@ type scenarioDeps struct {
newRunner runnerFactory
newRetryStorm func(gatewayURL string) scenarioCaseExecutor
defaultAgentURL string
+ defaultAIAgentURL string
defaultGatewayMCPURL string
+ mcpAddr string // TCP addr of upstream MCP (e.g. "localstripe-mcp:8421"); used for mcp-crash precondition
+ isMCPReachable func(addr string) bool // injectable for tests; defaults to probeTCP
+ larkURL string // healthz URL of Lark/mock-lark; used for approval-timeout precondition
+ isLarkReachable func(url string) bool // injectable for tests; defaults to probeHTTP
}
type scenarioCaseExecutor interface {
@@ -86,7 +92,7 @@ func makeScenarioStreamHandler(deps scenarioDeps) http.HandlerFunc {
switch body.ScenarioID {
case "mcp-crash", "approval-timeout":
- agentURL, err := resolveAbsoluteURL(body.AgentURL, deps.defaultAgentURL)
+ agentURL, err := resolveAbsoluteURL(serverPreferredURL(body.AgentURL), deps.defaultAIAgentURL)
if err != nil {
http.Error(w, "missing or invalid agent_url", http.StatusBadRequest)
return
@@ -106,9 +112,57 @@ func makeScenarioStreamHandler(deps scenarioDeps) http.HandlerFunc {
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
+ if body.ScenarioID == "mcp-crash" {
+ checkReachable := deps.isMCPReachable
+ if checkReachable == nil {
+ checkReachable = defaultMCPReachable
+ }
+ addr := deps.mcpAddr
+ if addr == "" {
+ addr = "127.0.0.1:18421"
+ }
+ if checkReachable(addr) {
+ preconditionFail := CaseResult{
+ Name: suite.Cases[0].Name,
+ Failures: []CheckFailure{{
+ Check: "precondition",
+ Expected: "MCP server unreachable",
+ Observed: "MCP server is still up — stop localstripe-mcp before running this scenario",
+ }},
+ }
+ _ = writeSSE(w, "case_start", caseStartEvent{Name: preconditionFail.Name, Index: 0, Total: 1})
+ _ = writeSSE(w, "case_result", caseResultEvent{Index: 0, Total: 1, Result: preconditionFail})
+ _ = writeSSE(w, "summary", summarizeResults([]CaseResult{preconditionFail}))
+ return
+ }
+ }
+ if body.ScenarioID == "approval-timeout" {
+ checkLark := deps.isLarkReachable
+ if checkLark == nil {
+ checkLark = defaultLarkReachable
+ }
+ larkURL := deps.larkURL
+ if larkURL == "" {
+ larkURL = "http://localhost:18090/healthz"
+ }
+ if checkLark(larkURL) {
+ preconditionFail := CaseResult{
+ Name: suite.Cases[0].Name,
+ Failures: []CheckFailure{{
+ Check: "precondition",
+ Expected: "Lark server unreachable",
+ Observed: "Lark server is still up — stop mock-lark before running this scenario",
+ }},
+ }
+ _ = writeSSE(w, "case_start", caseStartEvent{Name: preconditionFail.Name, Index: 0, Total: 1})
+ _ = writeSSE(w, "case_result", caseResultEvent{Index: 0, Total: 1, Result: preconditionFail})
+ _ = writeSSE(w, "summary", summarizeResults([]CaseResult{preconditionFail}))
+ return
+ }
+ }
streamEvalSuite(r.Context(), w, deps.newRunner(agentURL), suite.Cases)
case "retry-storm":
- gatewayURL, err := resolveGatewayMCPURL(body.GatewayMCPURL, deps.defaultGatewayMCPURL)
+ gatewayURL, err := resolveGatewayMCPURL(serverPreferredURL(body.GatewayMCPURL), deps.defaultGatewayMCPURL)
if err != nil {
http.Error(w, "missing or invalid gateway_mcp_url", http.StatusBadRequest)
return
@@ -186,6 +240,37 @@ func warmGatewayCapCache(gatewayMCPURL string) {
slog.Info("gateway warmup: capability cache primed", "gateway", gatewayMCPURL)
}
+func defaultMCPReachable(addr string) bool {
+ conn, err := net.DialTimeout("tcp", addr, 750*time.Millisecond)
+ if err != nil {
+ return false
+ }
+ _ = conn.Close()
+ return true
+}
+
+func defaultLarkReachable(healthzURL string) bool {
+ client := &http.Client{Timeout: 750 * time.Millisecond}
+ resp, err := client.Get(healthzURL)
+ if err != nil {
+ return false
+ }
+ _ = resp.Body.Close()
+ return resp.StatusCode >= 200 && resp.StatusCode < 300
+}
+
+// serverPreferredURL returns "" (causing fallback to the server-side default)
+// when the browser-provided value is a localhost/loopback URL. Inside Docker,
+// localhost resolves to the container itself, not the host, so browser-provided
+// localhost addresses must be replaced by the server's configured service URLs.
+func serverPreferredURL(requestValue string) string {
+ u := strings.TrimSpace(requestValue)
+ if strings.Contains(u, "localhost") || strings.Contains(u, "127.0.0.1") {
+ return ""
+ }
+ return u
+}
+
func resolveAbsoluteURL(requestValue, fallback string) (string, error) {
candidate := strings.TrimSpace(requestValue)
if candidate == "" {
diff --git a/cmd/eval-runner/scenarios_test.go b/cmd/eval-runner/scenarios_test.go
index ebb4331..2ac6313 100644
--- a/cmd/eval-runner/scenarios_test.go
+++ b/cmd/eval-runner/scenarios_test.go
@@ -125,7 +125,8 @@ func TestScenarioStreamYAMLScenarioUsesDefaultAgentURL(t *testing.T) {
rec := httptest.NewRecorder()
makeScenarioStreamHandler(scenarioDeps{
- defaultAgentURL: "http://agent.example",
+ defaultAIAgentURL: "http://agent.example",
+ isMCPReachable: func(string) bool { return false }, // simulate MCP down
newRunner: func(agentURL string) caseExecutor {
if agentURL != "http://agent.example" {
t.Fatalf("agentURL = %q, want default", agentURL)
@@ -142,6 +143,31 @@ func TestScenarioStreamYAMLScenarioUsesDefaultAgentURL(t *testing.T) {
}
}
+func TestScenarioStreamMCPCrashFailsPreconditionWhenMCPIsUp(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/run-scenario/stream", strings.NewReader(`{"scenario_id":"mcp-crash","agent_url":"http://agent.example"}`))
+ rec := httptest.NewRecorder()
+
+ makeScenarioStreamHandler(scenarioDeps{
+ defaultAIAgentURL: "http://agent.example",
+ isMCPReachable: func(string) bool { return true }, // simulate MCP still up
+ newRunner: func(agentURL string) caseExecutor {
+ t.Fatal("runner should not be called when precondition fails")
+ return nil
+ },
+ })(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (SSE stream)", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "precondition") {
+ t.Fatalf("expected precondition failure in SSE body, got: %s", body)
+ }
+ if !strings.Contains(body, "still up") {
+ t.Fatalf("expected 'still up' message in SSE body, got: %s", body)
+ }
+}
+
func TestScenarioStreamRejectsUnknownScenario(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/run-scenario/stream", strings.NewReader(`{"scenario_id":"unknown","agent_url":"http://agent.example"}`))
rec := httptest.NewRecorder()
diff --git a/cmd/eval-runner/serve.go b/cmd/eval-runner/serve.go
index ba8a0aa..9779586 100644
--- a/cmd/eval-runner/serve.go
+++ b/cmd/eval-runner/serve.go
@@ -91,7 +91,10 @@ func serve(suitePath string) error {
http.HandleFunc("POST /run-scenario/stream", makeScenarioStreamHandler(scenarioDeps{
pool: pool,
defaultAgentURL: cfg.AgentURL,
+ defaultAIAgentURL: aiAgentURL,
defaultGatewayMCPURL: os.Getenv("GATEWAY_MCP_URL"),
+ mcpAddr: os.Getenv("STACK_HEALTH_MCP_ADDR"),
+ larkURL: os.Getenv("STACK_HEALTH_LARK_URL"),
newRunner: func(agentURL string) caseExecutor {
return NewCaseRunner(agentURL, pool)
},
@@ -103,7 +106,17 @@ func serve(suitePath string) error {
http.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
- http.HandleFunc("GET /stack-health", makeStackHealthHandler(stackHealthDeps{pool: pool}))
+ http.HandleFunc("GET /stack-health", makeStackHealthHandler(stackHealthDeps{
+ pool: pool,
+ gatewayURL: func() string {
+ if u := os.Getenv("GATEWAY_MCP_URL"); u != "" {
+ return u
+ }
+ return "http://localhost:18080/mcp"
+ }(),
+ mcpAddr: os.Getenv("STACK_HEALTH_MCP_ADDR"),
+ larkURL: os.Getenv("STACK_HEALTH_LARK_URL"),
+ }))
gatewayMCPURL := os.Getenv("GATEWAY_MCP_URL")
if gatewayMCPURL == "" {
diff --git a/cmd/eval-runner/stack_health.go b/cmd/eval-runner/stack_health.go
index 82b3559..92679c9 100644
--- a/cmd/eval-runner/stack_health.go
+++ b/cmd/eval-runner/stack_health.go
@@ -25,16 +25,31 @@ type stackHealthService struct {
type stackHealthDeps struct {
pool *pgxpool.Pool
httpClient *http.Client
+ gatewayURL string
+ mcpAddr string
+ larkURL string
}
func makeStackHealthHandler(deps stackHealthDeps) http.HandlerFunc {
+ gatewayURL := deps.gatewayURL
+ if gatewayURL == "" {
+ gatewayURL = "http://localhost:18080/mcp"
+ }
+ mcpAddr := deps.mcpAddr
+ if mcpAddr == "" {
+ mcpAddr = "127.0.0.1:18421"
+ }
+ larkURL := deps.larkURL
+ if larkURL == "" {
+ larkURL = "http://localhost:18090/healthz"
+ }
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stackHealthResponse{
Services: []stackHealthService{
- probeHTTPService(deps.httpClient, "Gateway", "http://localhost:18080/mcp"),
- probeTCPService("MCP", "127.0.0.1:18421"),
- probeHTTPService(deps.httpClient, "Slack", "http://localhost:18090/healthz"),
+ probeHTTPService(deps.httpClient, "Gateway", gatewayURL),
+ probeTCPService("MCP", mcpAddr),
+ probeHTTPService(deps.httpClient, "Lark", larkURL),
probePostgresService(deps.pool),
},
})
diff --git a/cmd/eval-runner/suite_test.go b/cmd/eval-runner/suite_test.go
index deaeaac..2462d1f 100644
--- a/cmd/eval-runner/suite_test.go
+++ b/cmd/eval-runner/suite_test.go
@@ -29,9 +29,9 @@ cases:
mustInclude: [delete_record]
policyOutcome: deny
- - name: slack-pii-redact
- input: slack-pii-message
- mustInclude: [send_slack_message]
+ - name: lark-pii-redact
+ input: lark-pii-message
+ mustInclude: [send_lark_message]
policyOutcome: allow
mustNotContainInArgs: ["123-45-6789"]
`)
@@ -48,8 +48,8 @@ cases:
}
last := suite.Cases[3]
- if last.Name != "slack-pii-redact" {
- t.Fatalf("suite.Cases[3].Name = %q, want slack-pii-redact", last.Name)
+ if last.Name != "lark-pii-redact" {
+ t.Fatalf("suite.Cases[3].Name = %q, want lark-pii-redact", last.Name)
}
if got := len(last.MustNotContainInArgs); got != 1 || last.MustNotContainInArgs[0] != "123-45-6789" {
t.Fatalf("suite.Cases[3].MustNotContainInArgs = %v, want [123-45-6789]", last.MustNotContainInArgs)
diff --git a/cmd/eval-runner/ui.html b/cmd/eval-runner/ui.html
index 6bf1e7b..6e6674a 100644
--- a/cmd/eval-runner/ui.html
+++ b/cmd/eval-runner/ui.html
@@ -127,12 +127,12 @@
Results
},
'approval-timeout': {
label: 'Approval Timeout',
- description: 'Slack is down; approvalRequired expires after demo TTL.',
+ description: 'Lark is down; approvalRequired expires after demo TTL.',
mode: 'scenario',
agentUrl: 'http://127.0.0.1:18086',
- caseNames: ['approval-timeout-slack-down'],
+ caseNames: ['approval-timeout-lark-down'],
plan: `cases:
- - name: approval-timeout-slack-down
+ - name: approval-timeout-lark-down
input: >
List recent charges for alice@example.com, then issue a full refund on
the first non-refunded charge with reason requested_by_customer.
@@ -151,7 +151,7 @@ Results
- list_recent_charges
policyOutcome: upstream_error
- - name: approval-timeout-slack-down
+ - name: approval-timeout-lark-down
input: >
List recent charges for alice@example.com, then issue a full refund on
the first non-refunded charge with reason requested_by_customer.
diff --git a/cmd/gateway/approval_bridge.go b/cmd/gateway/approval_bridge.go
index 18eb9c4..e5ced39 100644
--- a/cmd/gateway/approval_bridge.go
+++ b/cmd/gateway/approval_bridge.go
@@ -12,6 +12,8 @@ import (
// ErrApprovalTimeout is returned when no decision arrives within the timeout window.
var ErrApprovalTimeout = errors.New("approval timeout")
+const defaultApprovalTimeout = 5 * time.Minute
+
// ApprovalDecision is the outcome of a completed approval wait.
type ApprovalDecision struct {
Approved bool
@@ -114,6 +116,9 @@ func NewRedisApprovalBridge(
if log == nil {
log = slog.Default()
}
+ if approvalTimeout <= 0 {
+ approvalTimeout = defaultApprovalTimeout
+ }
b := &RedisApprovalBridge{
redis: rdb,
tickets: tickets,
diff --git a/cmd/gateway/approval_bridge_integration_test.go b/cmd/gateway/approval_bridge_integration_test.go
index dac4f29..82bc263 100644
--- a/cmd/gateway/approval_bridge_integration_test.go
+++ b/cmd/gateway/approval_bridge_integration_test.go
@@ -211,7 +211,7 @@ func newApprovalBridgeIntegrationHarness(t *testing.T, timeout, lockTTL, lockExt
store := NewTicketStore(pool)
locker := NewSessionLocker(redisClient, lockTTL, 250*time.Millisecond)
- bridge := NewRedisApprovalBridge(redisClient, store, locker, lockTTL, 5*time.Minute, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ bridge := NewRedisApprovalBridge(redisClient, store, locker, lockTTL, defaultApprovalTimeout, slog.New(slog.NewTextHandler(io.Discard, nil)))
bridge.timeout = timeout
bridge.lockExtendInterval = lockExtendInterval
diff --git a/cmd/gateway/config.go b/cmd/gateway/config.go
index e06b030..fd27ebd 100644
--- a/cmd/gateway/config.go
+++ b/cmd/gateway/config.go
@@ -30,11 +30,12 @@ type Config struct {
SessionTTL time.Duration
SessionLockTTL time.Duration
LockAcquireTimeout time.Duration
- ApprovalLockTTL time.Duration // APPROVAL_LOCK_TTL (optional, default 5m)
- SlackBotToken string // SLACK_BOT_TOKEN (required)
- SlackSigningSecret string // SLACK_SIGNING_SECRET (required)
- SlackChannel string // SLACK_CHANNEL (required)
- SlackAPIBaseURL string // SLACK_API_BASE_URL (optional, default "https://slack.com/api")
+ ApprovalTimeout time.Duration
+ LarkAppID string // LARK_APP_ID (required)
+ LarkAppSecret string // LARK_APP_SECRET (required)
+ LarkChatID string // LARK_CHAT_ID (required)
+ LarkVerificationToken string // LARK_VERIFICATION_TOKEN (required)
+ LarkAPIBaseURL string // LARK_API_BASE_URL (optional, default "https://open.feishu.cn/open-apis")
}
func LoadConfig() (*Config, error) {
@@ -51,21 +52,25 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("missing required environment variable REDIS_DSN")
}
- slackBotToken := os.Getenv("SLACK_BOT_TOKEN")
- slackSigningSecret := os.Getenv("SLACK_SIGNING_SECRET")
- slackChannel := os.Getenv("SLACK_CHANNEL")
- var missingSlack []string
- if slackBotToken == "" {
- missingSlack = append(missingSlack, "SLACK_BOT_TOKEN")
+ larkAppID := os.Getenv("LARK_APP_ID")
+ larkAppSecret := os.Getenv("LARK_APP_SECRET")
+ larkChatID := os.Getenv("LARK_CHAT_ID")
+ larkVerificationToken := os.Getenv("LARK_VERIFICATION_TOKEN")
+ var missingLark []string
+ if larkAppID == "" {
+ missingLark = append(missingLark, "LARK_APP_ID")
}
- if slackSigningSecret == "" {
- missingSlack = append(missingSlack, "SLACK_SIGNING_SECRET")
+ if larkAppSecret == "" {
+ missingLark = append(missingLark, "LARK_APP_SECRET")
}
- if slackChannel == "" {
- missingSlack = append(missingSlack, "SLACK_CHANNEL")
+ if larkChatID == "" {
+ missingLark = append(missingLark, "LARK_CHAT_ID")
}
- if len(missingSlack) > 0 {
- return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missingSlack, ", "))
+ if larkVerificationToken == "" {
+ missingLark = append(missingLark, "LARK_VERIFICATION_TOKEN")
+ }
+ if len(missingLark) > 0 {
+ return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missingLark, ", "))
}
listenPort, err := envInt("GATEWAY_PORT", defaultGatewayPort)
@@ -93,7 +98,7 @@ func LoadConfig() (*Config, error) {
return nil, err
}
- approvalLockTTL, err := envDuration("APPROVAL_LOCK_TTL", 5*time.Minute)
+ approvalTimeout, err := envDuration("APPROVAL_TIMEOUT", defaultApprovalTimeout)
if err != nil {
return nil, err
}
@@ -109,11 +114,12 @@ func LoadConfig() (*Config, error) {
SessionTTL: sessionTTL,
SessionLockTTL: sessionLockTTL,
LockAcquireTimeout: lockAcquireTimeout,
- ApprovalLockTTL: approvalLockTTL,
- SlackBotToken: slackBotToken,
- SlackSigningSecret: slackSigningSecret,
- SlackChannel: slackChannel,
- SlackAPIBaseURL: envString("SLACK_API_BASE_URL", slackAPIBaseURL),
+ ApprovalTimeout: approvalTimeout,
+ LarkAppID: larkAppID,
+ LarkAppSecret: larkAppSecret,
+ LarkChatID: larkChatID,
+ LarkVerificationToken: larkVerificationToken,
+ LarkAPIBaseURL: envString("LARK_API_BASE_URL", larkAPIBaseURL),
}, nil
}
diff --git a/cmd/gateway/config_test.go b/cmd/gateway/config_test.go
index 1a1e1e6..2137cb8 100644
--- a/cmd/gateway/config_test.go
+++ b/cmd/gateway/config_test.go
@@ -43,9 +43,10 @@ func TestLoadConfigDefaultsWithOnlyUpstreamMCPURL(t *testing.T) {
t.Setenv("SESSION_TTL", "")
t.Setenv("SESSION_LOCK_TTL", "")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-default-token")
- t.Setenv("SLACK_SIGNING_SECRET", "default-signing-secret")
- t.Setenv("SLACK_CHANNEL", "#approvals")
+ t.Setenv("LARK_APP_ID", "cli_demo_app_id")
+ t.Setenv("LARK_APP_SECRET", "demo_app_secret")
+ t.Setenv("LARK_CHAT_ID", "oc_demo_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "demo_verification_token")
var logs bytes.Buffer
restoreDefaultLogger := setDefaultLoggerForTest(&logs)
@@ -107,9 +108,10 @@ func TestLoadConfigReadsEnvironmentOverrides(t *testing.T) {
t.Setenv("SESSION_TTL", "2h")
t.Setenv("SESSION_LOCK_TTL", "90s")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "7s")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-override-token")
- t.Setenv("SLACK_SIGNING_SECRET", "override-signing-secret")
- t.Setenv("SLACK_CHANNEL", "#override-approvals")
+ t.Setenv("LARK_APP_ID", "cli_override")
+ t.Setenv("LARK_APP_SECRET", "override_secret")
+ t.Setenv("LARK_CHAT_ID", "oc_override_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "override_token")
cfg, err := LoadConfig()
if err != nil {
@@ -195,7 +197,7 @@ func TestLoadConfigRequiresRedisDSN(t *testing.T) {
}
}
-func TestLoadConfigRequiresSlackBotToken(t *testing.T) {
+func TestLoadConfigRequiresLarkAppID(t *testing.T) {
t.Setenv("GATEWAY_PORT", "")
t.Setenv("POLICY_FILE", "")
t.Setenv("POSTGRES_DSN", "postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable")
@@ -206,23 +208,24 @@ func TestLoadConfigRequiresSlackBotToken(t *testing.T) {
t.Setenv("SESSION_TTL", "")
t.Setenv("SESSION_LOCK_TTL", "")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
- t.Setenv("SLACK_BOT_TOKEN", "")
- t.Setenv("SLACK_SIGNING_SECRET", "xsecret")
- t.Setenv("SLACK_CHANNEL", "#approvals")
+ t.Setenv("LARK_APP_ID", "")
+ t.Setenv("LARK_APP_SECRET", "secret")
+ t.Setenv("LARK_CHAT_ID", "oc_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "token")
cfg, err := LoadConfig()
if err == nil {
- t.Fatalf("LoadConfig() error = nil, want missing SLACK_BOT_TOKEN error")
+ t.Fatalf("LoadConfig() error = nil, want missing LARK_APP_ID error")
}
if cfg != nil {
t.Fatalf("LoadConfig() config = %#v, want nil config on error", cfg)
}
- if !strings.Contains(err.Error(), "SLACK_BOT_TOKEN") {
- t.Fatalf("LoadConfig() error = %q, want message naming SLACK_BOT_TOKEN", err.Error())
+ if !strings.Contains(err.Error(), "LARK_APP_ID") {
+ t.Fatalf("LoadConfig() error = %q, want message naming LARK_APP_ID", err.Error())
}
}
-func TestLoadConfigRequiresSlackSigningSecret(t *testing.T) {
+func TestLoadConfigRequiresLarkAppSecret(t *testing.T) {
t.Setenv("GATEWAY_PORT", "")
t.Setenv("POLICY_FILE", "")
t.Setenv("POSTGRES_DSN", "postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable")
@@ -233,23 +236,24 @@ func TestLoadConfigRequiresSlackSigningSecret(t *testing.T) {
t.Setenv("SESSION_TTL", "")
t.Setenv("SESSION_LOCK_TTL", "")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-token")
- t.Setenv("SLACK_SIGNING_SECRET", "")
- t.Setenv("SLACK_CHANNEL", "#approvals")
+ t.Setenv("LARK_APP_ID", "cli_app")
+ t.Setenv("LARK_APP_SECRET", "")
+ t.Setenv("LARK_CHAT_ID", "oc_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "token")
cfg, err := LoadConfig()
if err == nil {
- t.Fatalf("LoadConfig() error = nil, want missing SLACK_SIGNING_SECRET error")
+ t.Fatalf("LoadConfig() error = nil, want missing LARK_APP_SECRET error")
}
if cfg != nil {
t.Fatalf("LoadConfig() config = %#v, want nil config on error", cfg)
}
- if !strings.Contains(err.Error(), "SLACK_SIGNING_SECRET") {
- t.Fatalf("LoadConfig() error = %q, want message naming SLACK_SIGNING_SECRET", err.Error())
+ if !strings.Contains(err.Error(), "LARK_APP_SECRET") {
+ t.Fatalf("LoadConfig() error = %q, want message naming LARK_APP_SECRET", err.Error())
}
}
-func TestLoadConfigRequiresSlackChannel(t *testing.T) {
+func TestLoadConfigRequiresLarkChatID(t *testing.T) {
t.Setenv("GATEWAY_PORT", "")
t.Setenv("POLICY_FILE", "")
t.Setenv("POSTGRES_DSN", "postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable")
@@ -260,23 +264,24 @@ func TestLoadConfigRequiresSlackChannel(t *testing.T) {
t.Setenv("SESSION_TTL", "")
t.Setenv("SESSION_LOCK_TTL", "")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-token")
- t.Setenv("SLACK_SIGNING_SECRET", "xsecret")
- t.Setenv("SLACK_CHANNEL", "")
+ t.Setenv("LARK_APP_ID", "cli_app")
+ t.Setenv("LARK_APP_SECRET", "secret")
+ t.Setenv("LARK_CHAT_ID", "")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "token")
cfg, err := LoadConfig()
if err == nil {
- t.Fatalf("LoadConfig() error = nil, want missing SLACK_CHANNEL error")
+ t.Fatalf("LoadConfig() error = nil, want missing LARK_CHAT_ID error")
}
if cfg != nil {
t.Fatalf("LoadConfig() config = %#v, want nil config on error", cfg)
}
- if !strings.Contains(err.Error(), "SLACK_CHANNEL") {
- t.Fatalf("LoadConfig() error = %q, want message naming SLACK_CHANNEL", err.Error())
+ if !strings.Contains(err.Error(), "LARK_CHAT_ID") {
+ t.Fatalf("LoadConfig() error = %q, want message naming LARK_CHAT_ID", err.Error())
}
}
-func TestLoadConfigReportsAllMissingSlackVars(t *testing.T) {
+func TestLoadConfigRequiresLarkVerificationToken(t *testing.T) {
t.Setenv("GATEWAY_PORT", "")
t.Setenv("POLICY_FILE", "")
t.Setenv("POSTGRES_DSN", "postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable")
@@ -287,29 +292,54 @@ func TestLoadConfigReportsAllMissingSlackVars(t *testing.T) {
t.Setenv("SESSION_TTL", "")
t.Setenv("SESSION_LOCK_TTL", "")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
- t.Setenv("SLACK_BOT_TOKEN", "")
- t.Setenv("SLACK_SIGNING_SECRET", "")
- t.Setenv("SLACK_CHANNEL", "")
+ t.Setenv("LARK_APP_ID", "cli_app")
+ t.Setenv("LARK_APP_SECRET", "secret")
+ t.Setenv("LARK_CHAT_ID", "oc_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "")
cfg, err := LoadConfig()
if err == nil {
- t.Fatalf("LoadConfig() error = nil, want missing Slack vars error")
+ t.Fatalf("LoadConfig() error = nil, want missing LARK_VERIFICATION_TOKEN error")
}
if cfg != nil {
t.Fatalf("LoadConfig() config = %#v, want nil config on error", cfg)
}
- if !strings.Contains(err.Error(), "SLACK_BOT_TOKEN") {
- t.Fatalf("LoadConfig() error = %q, want message naming SLACK_BOT_TOKEN", err.Error())
+ if !strings.Contains(err.Error(), "LARK_VERIFICATION_TOKEN") {
+ t.Fatalf("LoadConfig() error = %q, want message naming LARK_VERIFICATION_TOKEN", err.Error())
}
- if !strings.Contains(err.Error(), "SLACK_SIGNING_SECRET") {
- t.Fatalf("LoadConfig() error = %q, want message naming SLACK_SIGNING_SECRET", err.Error())
+}
+
+func TestLoadConfigReportsAllMissingLarkVars(t *testing.T) {
+ t.Setenv("GATEWAY_PORT", "")
+ t.Setenv("POLICY_FILE", "")
+ t.Setenv("POSTGRES_DSN", "postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable")
+ t.Setenv("REDIS_DSN", "redis://localhost:6379/0")
+ t.Setenv("UPSTREAM_MCP_URL", "http://upstream.example/mcp")
+ t.Setenv("TURN_ID_HEADER", "")
+ t.Setenv("UPSTREAM_TIMEOUT", "")
+ t.Setenv("SESSION_TTL", "")
+ t.Setenv("SESSION_LOCK_TTL", "")
+ t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
+ t.Setenv("LARK_APP_ID", "")
+ t.Setenv("LARK_APP_SECRET", "")
+ t.Setenv("LARK_CHAT_ID", "")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "")
+
+ cfg, err := LoadConfig()
+ if err == nil {
+ t.Fatalf("LoadConfig() error = nil, want missing Lark vars error")
+ }
+ if cfg != nil {
+ t.Fatalf("LoadConfig() config = %#v, want nil config on error", cfg)
}
- if !strings.Contains(err.Error(), "SLACK_CHANNEL") {
- t.Fatalf("LoadConfig() error = %q, want message naming SLACK_CHANNEL", err.Error())
+ for _, want := range []string{"LARK_APP_ID", "LARK_APP_SECRET", "LARK_CHAT_ID", "LARK_VERIFICATION_TOKEN"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("LoadConfig() error = %q, want message naming %s", err.Error(), want)
+ }
}
}
-func TestLoadConfigReadsSlackVars(t *testing.T) {
+func TestLoadConfigReadsLarkVars(t *testing.T) {
t.Setenv("GATEWAY_PORT", "")
t.Setenv("POLICY_FILE", "")
t.Setenv("POSTGRES_DSN", "postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable")
@@ -320,9 +350,10 @@ func TestLoadConfigReadsSlackVars(t *testing.T) {
t.Setenv("SESSION_TTL", "")
t.Setenv("SESSION_LOCK_TTL", "")
t.Setenv("LOCK_ACQUIRE_TIMEOUT", "")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-test-token")
- t.Setenv("SLACK_SIGNING_SECRET", "test-signing-secret")
- t.Setenv("SLACK_CHANNEL", "#test-approvals")
+ t.Setenv("LARK_APP_ID", "cli_test_app")
+ t.Setenv("LARK_APP_SECRET", "test_app_secret")
+ t.Setenv("LARK_CHAT_ID", "oc_test_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "test_verification_token")
var logs bytes.Buffer
restoreDefaultLogger := setDefaultLoggerForTest(&logs)
@@ -335,42 +366,20 @@ func TestLoadConfigReadsSlackVars(t *testing.T) {
if cfg == nil {
t.Fatalf("LoadConfig() config = nil, want config")
}
- if cfg.SlackBotToken != "xoxb-test-token" {
- t.Fatalf("SlackBotToken = %q, want xoxb-test-token", cfg.SlackBotToken)
+ if cfg.LarkAppID != "cli_test_app" {
+ t.Fatalf("LarkAppID = %q, want cli_test_app", cfg.LarkAppID)
}
- if cfg.SlackSigningSecret != "test-signing-secret" {
- t.Fatalf("SlackSigningSecret = %q, want test-signing-secret", cfg.SlackSigningSecret)
+ if cfg.LarkAppSecret != "test_app_secret" {
+ t.Fatalf("LarkAppSecret = %q, want test_app_secret", cfg.LarkAppSecret)
}
- if cfg.SlackChannel != "#test-approvals" {
- t.Fatalf("SlackChannel = %q, want #test-approvals", cfg.SlackChannel)
+ if cfg.LarkChatID != "oc_test_chat" {
+ t.Fatalf("LarkChatID = %q, want oc_test_chat", cfg.LarkChatID)
}
-}
-
-func TestLoadConfigReadsApprovalLockTTL(t *testing.T) {
- setRequiredEnv(t)
- t.Setenv("APPROVAL_LOCK_TTL", "15s")
-
- cfg, err := LoadConfig()
- if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
- }
- if cfg.ApprovalLockTTL != 15*time.Second {
- t.Fatalf("ApprovalLockTTL = %v, want 15s", cfg.ApprovalLockTTL)
+ if cfg.LarkVerificationToken != "test_verification_token" {
+ t.Fatalf("LarkVerificationToken = %q, want test_verification_token", cfg.LarkVerificationToken)
}
}
-func TestLoadConfigDefaultsApprovalLockTTLToFiveMinutes(t *testing.T) {
- setRequiredEnv(t)
- t.Setenv("APPROVAL_LOCK_TTL", "")
-
- cfg, err := LoadConfig()
- if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
- }
- if cfg.ApprovalLockTTL != 5*time.Minute {
- t.Fatalf("ApprovalLockTTL = %v, want 5m0s", cfg.ApprovalLockTTL)
- }
-}
func setRequiredEnv(t *testing.T) {
t.Helper()
diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go
index e65ff07..f4a6770 100644
--- a/cmd/gateway/main.go
+++ b/cmd/gateway/main.go
@@ -85,10 +85,10 @@ func buildGatewayServer(ctx context.Context, config *Config, logger *slog.Logger
auditWriter.Start(ctx)
ticketStore := NewTicketStore(pool)
sessionLocker := NewSessionLocker(redisClient, config.SessionLockTTL, config.LockAcquireTimeout)
- slackNotifier := NewSlackClient(config.SlackBotToken, config.SlackChannel, config.SlackAPIBaseURL, logger)
- approvalBridge := NewRedisApprovalBridge(redisClient, ticketStore, sessionLocker, config.SessionLockTTL, config.ApprovalLockTTL, logger)
- slackWebhook := NewSlackWebhookHandler(config.SlackSigningSecret, ticketStore, redisClient, logger)
- policyGate := NewPolicyGateHandler(policy, budgetTracker, auditWriter, ticketStore, approvalBridge, slackNotifier, logger)
+ larkNotifier := NewLarkClient(config.LarkAppID, config.LarkAppSecret, config.LarkChatID, config.LarkAPIBaseURL, logger)
+ approvalBridge := NewRedisApprovalBridge(redisClient, ticketStore, sessionLocker, config.SessionLockTTL, config.ApprovalTimeout, logger)
+ larkWebhook := NewLarkWebhookHandler(config.LarkVerificationToken, ticketStore, redisClient, logger)
+ policyGate := NewPolicyGateHandler(policy, budgetTracker, auditWriter, ticketStore, approvalBridge, larkNotifier, logger)
turnRWLock := NewTurnRWLock(redisClient, config.SessionLockTTL, config.LockAcquireTimeout)
classifier := NewOperationClassifier(policy.OperationClasses)
guard := NewConcurrencyGuard(sessionLocker, turnRWLock, classifier)
@@ -100,10 +100,10 @@ func buildGatewayServer(ctx context.Context, config *Config, logger *slog.Logger
pipeline.Use(policyGate)
server := NewServer(config, pipeline, logger)
- server.audit = auditWriter
server.forwarder = forwarder
+ server.audit = auditWriter
server.guard = guard
- server.SetSlackWebhookHandler(slackWebhook)
+ server.SetWebhookHandler(larkWebhook)
return server, cleanup, nil
}
diff --git a/cmd/gateway/main_test.go b/cmd/gateway/main_test.go
index ee78ec2..e38bf9c 100644
--- a/cmd/gateway/main_test.go
+++ b/cmd/gateway/main_test.go
@@ -53,9 +53,10 @@ func TestRunGatewayFatalfsWhenPolicyLoadFails(t *testing.T) {
t.Setenv("UPSTREAM_MCP_URL", "http://example.invalid")
t.Setenv("POSTGRES_DSN", "postgres://localhost:5432/toolgate?sslmode=disable")
t.Setenv("REDIS_DSN", "redis://localhost:6379/0")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-test-token")
- t.Setenv("SLACK_SIGNING_SECRET", "test-signing-secret")
- t.Setenv("SLACK_CHANNEL", "#approvals")
+ t.Setenv("LARK_APP_ID", "cli_test_app")
+ t.Setenv("LARK_APP_SECRET", "test_app_secret")
+ t.Setenv("LARK_CHAT_ID", "oc_test_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "test_verification_token")
t.Setenv("POLICY_FILE", filepath.Join(t.TempDir(), "missing-policy.yaml"))
message := interceptFatalf(t, func() {
@@ -74,9 +75,10 @@ func TestRunGatewayFatalfsWhenPolicyYAMLIsInvalid(t *testing.T) {
t.Setenv("UPSTREAM_MCP_URL", "http://example.invalid")
t.Setenv("POSTGRES_DSN", "postgres://localhost:5432/toolgate?sslmode=disable")
t.Setenv("REDIS_DSN", "redis://localhost:6379/0")
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-test-token")
- t.Setenv("SLACK_SIGNING_SECRET", "test-signing-secret")
- t.Setenv("SLACK_CHANNEL", "#approvals")
+ t.Setenv("LARK_APP_ID", "cli_test_app")
+ t.Setenv("LARK_APP_SECRET", "test_app_secret")
+ t.Setenv("LARK_CHAT_ID", "oc_test_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "test_verification_token")
t.Setenv("POLICY_FILE", writePolicyFile(t, "rules: ["))
message := interceptFatalf(t, func() {
@@ -95,9 +97,10 @@ func TestRunGatewayFatalfsWhenPostgresInitFails(t *testing.T) {
t.Setenv("UPSTREAM_MCP_URL", "http://example.invalid")
t.Setenv("POSTGRES_DSN", "postgres://127.0.0.1:1/toolgate?sslmode=disable&connect_timeout=1")
t.Setenv("REDIS_DSN", testRedisDSN(t))
- t.Setenv("SLACK_BOT_TOKEN", "xoxb-test-token")
- t.Setenv("SLACK_SIGNING_SECRET", "test-signing-secret")
- t.Setenv("SLACK_CHANNEL", "#approvals")
+ t.Setenv("LARK_APP_ID", "cli_test_app")
+ t.Setenv("LARK_APP_SECRET", "test_app_secret")
+ t.Setenv("LARK_CHAT_ID", "oc_test_chat")
+ t.Setenv("LARK_VERIFICATION_TOKEN", "test_verification_token")
t.Setenv("POLICY_FILE", writePolicyFile(t, `
defaultAction: deny
budgets:
@@ -215,9 +218,10 @@ rules:
SessionTTL: time.Minute,
SessionLockTTL: defaultSessionLockTTL,
LockAcquireTimeout: defaultLockAcquireTimeout,
- SlackBotToken: "test-token",
- SlackSigningSecret: "test-secret",
- SlackChannel: "#test",
+ LarkAppID: "cli_test",
+ LarkAppSecret: "test-secret",
+ LarkChatID: "oc_test",
+ LarkVerificationToken: "test-token",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -258,7 +262,7 @@ rules:
}
}
-func TestBuildGatewayServerRegistersSlackWebhookRoute(t *testing.T) {
+func TestBuildGatewayServerRegistersLarkWebhookRoute(t *testing.T) {
ctx := context.Background()
dsn := testSchemaDSN(t, testPostgresDSN(t))
@@ -279,9 +283,10 @@ budgets:
SessionTTL: time.Minute,
SessionLockTTL: time.Minute,
LockAcquireTimeout: 250 * time.Millisecond,
- SlackBotToken: "xoxb-test-token",
- SlackSigningSecret: "test-signing-secret",
- SlackChannel: "#approvals",
+ LarkAppID: "cli_test_app",
+ LarkAppSecret: "test_app_secret",
+ LarkChatID: "oc_test_chat",
+ LarkVerificationToken: "test-verification-token",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -294,21 +299,22 @@ budgets:
ts := httptest.NewServer(server)
defer ts.Close()
- req := httptest.NewRequest(http.MethodPost, "/slack/actions", strings.NewReader("payload=%7B%7D"))
+ // A request with missing Lark signature headers should return 400.
+ req := httptest.NewRequest(http.MethodPost, "/lark/actions", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
- t.Fatalf("POST /slack/actions status = %d, want %d", rec.Code, http.StatusBadRequest)
+ t.Fatalf("POST /lark/actions status = %d, want %d", rec.Code, http.StatusBadRequest)
}
- resp, err := http.Post(ts.URL+"/slack/actions", "application/x-www-form-urlencoded", strings.NewReader("payload=%7B%7D"))
+ resp, err := http.Post(ts.URL+"/lark/actions", "application/json", strings.NewReader(`{}`))
if err != nil {
- t.Fatalf("POST /slack/actions via httptest server: %v", err)
+ t.Fatalf("POST /lark/actions via httptest server: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
- t.Fatalf("network POST /slack/actions status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
+ t.Fatalf("network POST /lark/actions status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
}
diff --git a/cmd/gateway/policy_gate.go b/cmd/gateway/policy_gate.go
index 6d81e5b..76212e0 100644
--- a/cmd/gateway/policy_gate.go
+++ b/cmd/gateway/policy_gate.go
@@ -26,13 +26,13 @@ type ticketInserter interface {
}
type policyEvaluator interface {
- Evaluate(policy *corepolicy.AgentPolicy, toolName string) corepolicy.PolicyDecision
+ Evaluate(policy *corepolicy.AgentPolicy, toolName string, args json.RawMessage) corepolicy.PolicyDecision
}
type defaultPolicyEvaluator struct{}
-func (defaultPolicyEvaluator) Evaluate(policy *corepolicy.AgentPolicy, toolName string) corepolicy.PolicyDecision {
- return corepolicy.Evaluate(policy, toolName)
+func (defaultPolicyEvaluator) Evaluate(policy *corepolicy.AgentPolicy, toolName string, args json.RawMessage) corepolicy.PolicyDecision {
+ return corepolicy.Evaluate(policy, toolName, args)
}
type BudgetTracker struct {
@@ -62,7 +62,7 @@ type PolicyGateHandler struct {
tickets ticketInserter
evaluator policyEvaluator
bridge ApprovalBridge
- notifier SlackNotifier
+ notifier ApprovalNotifier
log *slog.Logger
now func() time.Time
}
@@ -73,7 +73,7 @@ func NewPolicyGateHandler(
audit *AuditWriter,
tickets *TicketStore,
bridge ApprovalBridge,
- notifier SlackNotifier,
+ notifier ApprovalNotifier,
log *slog.Logger,
) *PolicyGateHandler {
return newPolicyGateHandler(policy, budget, audit, tickets, defaultPolicyEvaluator{}, bridge, notifier, log, time.Now)
@@ -86,7 +86,7 @@ func newPolicyGateHandler(
tickets ticketInserter,
evaluator policyEvaluator,
bridge ApprovalBridge,
- notifier SlackNotifier,
+ notifier ApprovalNotifier,
log *slog.Logger,
now func() time.Time,
) *PolicyGateHandler {
@@ -160,7 +160,7 @@ func (h *PolicyGateHandler) Handle(ctx context.Context, req *mcp.JSONRPCRequest)
return mcp.NewErrorResponse(req.ID, mcp.CodePolicyDenied, "tool-call budget exceeded"), nil
}
- decision := h.evaluator.Evaluate(h.policy, toolName)
+ decision := h.evaluator.Evaluate(h.policy, toolName, arguments)
if decision.Action != corepolicy.ActionRedact {
h.audit.Write(AuditRecord{
SessionID: sessionID,
@@ -205,7 +205,7 @@ func (h *PolicyGateHandler) Handle(ctx context.Context, req *mcp.JSONRPCRequest)
ToolName: toolName,
Arguments: arguments,
}); err != nil {
- h.log.Error("slack notification failed", "ticketID", ticketID, "error", err)
+ h.log.Error("lark notification failed", "ticketID", ticketID, "error", err)
}
}()
diff --git a/cmd/gateway/policy_gate_integration_test.go b/cmd/gateway/policy_gate_integration_test.go
index 1aa8291..a43b630 100644
--- a/cmd/gateway/policy_gate_integration_test.go
+++ b/cmd/gateway/policy_gate_integration_test.go
@@ -242,9 +242,10 @@ func newPolicyGateIntegrationHarness(t *testing.T, policyContents string) (*pgxp
SessionTTL: time.Minute,
SessionLockTTL: defaultSessionLockTTL,
LockAcquireTimeout: defaultLockAcquireTimeout,
- SlackBotToken: "test-token",
- SlackSigningSecret: "test-secret",
- SlackChannel: "#test",
+ LarkAppID: "cli_test",
+ LarkAppSecret: "test-secret",
+ LarkChatID: "oc_test",
+ LarkVerificationToken: "test-token",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
server, cleanupServer, err := buildGatewayServer(ctx, config, logger)
diff --git a/cmd/gateway/policy_gate_test.go b/cmd/gateway/policy_gate_test.go
index 0c879e7..de0859f 100644
--- a/cmd/gateway/policy_gate_test.go
+++ b/cmd/gateway/policy_gate_test.go
@@ -142,7 +142,7 @@ func TestPolicyGateHandlerApprovalRequiredInsertsTicketAndCallsBridge(t *testing
audit := &policyGateAuditStub{}
tickets := &policyGateTicketStub{}
bridge := &mockApprovalBridge{decision: ApprovalDecision{Approved: true, TicketID: "ticket-1"}}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -190,7 +190,7 @@ func TestPolicyGateHandlerApprovalRequiredInsertsTicketAndCallsBridge(t *testing
func TestPolicyGateHandlerApprovalRequiredLogsTicketInsertFailureAndContinuesHold(t *testing.T) {
var buf bytes.Buffer
bridge := &mockApprovalBridge{decision: ApprovalDecision{Approved: true, TicketID: ""}}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -358,7 +358,7 @@ type policyGateEvaluatorStub struct {
decision corepolicy.PolicyDecision
}
-func (s *policyGateEvaluatorStub) Evaluate(policy *corepolicy.AgentPolicy, toolName string) corepolicy.PolicyDecision {
+func (s *policyGateEvaluatorStub) Evaluate(policy *corepolicy.AgentPolicy, toolName string, args json.RawMessage) corepolicy.PolicyDecision {
s.calls++
s.toolName = toolName
return s.decision
@@ -382,7 +382,7 @@ func nowStub(now time.Time) func() time.Time {
func TestPolicyGateHandlerApprovalRequiredErrorResponseShape(t *testing.T) {
// Verify the error response shape matches the spec: code -32001, message "approval denied"
bridge := &mockApprovalBridge{decision: ApprovalDecision{Approved: false}}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 1}},
NewBudgetTracker(),
@@ -460,20 +460,20 @@ func (m *mockApprovalBridge) WaitForDecision(_ context.Context, _, _, _ string)
return m.decision, m.err
}
-// mockSlackNotifier is a test double for SlackNotifier.
-type mockSlackNotifier struct {
+// mockApprovalNotifier is a test double for ApprovalNotifier.
+type mockApprovalNotifier struct {
err error
sendCalled chan struct{}
}
-func newMockSlackNotifier(err error) *mockSlackNotifier {
- return &mockSlackNotifier{
+func newMockApprovalNotifier(err error) *mockApprovalNotifier {
+ return &mockApprovalNotifier{
err: err,
sendCalled: make(chan struct{}, 1),
}
}
-func (m *mockSlackNotifier) SendApprovalRequest(_ context.Context, _ string, _ TicketRecord) error {
+func (m *mockApprovalNotifier) SendApprovalRequest(_ context.Context, _ string, _ TicketRecord) error {
m.sendCalled <- struct{}{}
return m.err
}
@@ -497,7 +497,7 @@ func (b *policyGateLockedBuffer) String() string {
func TestPolicyGateHandlerApprovalHoldApprovedReturnsContinue(t *testing.T) {
bridge := &mockApprovalBridge{decision: ApprovalDecision{Approved: true, TicketID: "ticket-1"}}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -524,7 +524,7 @@ func TestPolicyGateHandlerApprovalHoldApprovedReturnsContinue(t *testing.T) {
func TestPolicyGateHandlerApprovalHoldDeniedReturnsError(t *testing.T) {
bridge := &mockApprovalBridge{decision: ApprovalDecision{Approved: false, TicketID: "ticket-1"}}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -554,7 +554,7 @@ func TestPolicyGateHandlerApprovalHoldDeniedReturnsError(t *testing.T) {
func TestPolicyGateHandlerApprovalHoldBridgeErrorReturnsDenied(t *testing.T) {
bridge := &mockApprovalBridge{err: errors.New("bridge internal error")}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -585,7 +585,7 @@ func TestPolicyGateHandlerApprovalHoldBridgeErrorReturnsDenied(t *testing.T) {
func TestPolicyGateHandlerApprovalHoldTimeoutReturnsTimeoutError(t *testing.T) {
audit := &policyGateAuditStub{}
bridge := &mockApprovalBridge{err: ErrApprovalTimeout}
- notifier := newMockSlackNotifier(nil)
+ notifier := newMockApprovalNotifier(nil)
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -651,7 +651,7 @@ func TestPolicyGateHandlerRedactMasksFieldAndAuditsAllow(t *testing.T) {
JSONRPC: "2.0",
ID: json.RawMessage(`1`),
Method: "tools/call",
- Params: json.RawMessage(`{"name":"send_slack_message","arguments":{"message":"secret content","channel":"#general"}}`),
+ Params: json.RawMessage(`{"name":"send_lark_message","arguments":{"message":"secret content","channel":"#general"}}`),
}
resp, err := handler.Handle(contextWithSessionAndTurn("session-redact", "turn-redact"), req)
@@ -725,7 +725,7 @@ func TestPolicyGateHandlerRedactSkipsMissingField(t *testing.T) {
JSONRPC: "2.0",
ID: json.RawMessage(`1`),
Method: "tools/call",
- Params: json.RawMessage(`{"name":"send_slack_message","arguments":{"channel":"#general"}}`),
+ Params: json.RawMessage(`{"name":"send_lark_message","arguments":{"channel":"#general"}}`),
}
resp, err := handler.Handle(contextWithSessionAndTurn("session-redact-skip", "turn-redact-skip"), req)
@@ -748,7 +748,7 @@ func TestPolicyGateHandlerApprovalHoldNotifierErrorDoesNotBlockBridge(t *testing
// Even if notifier returns an error, WaitForDecision must still be called.
var buf policyGateLockedBuffer
bridge := &mockApprovalBridge{decision: ApprovalDecision{Approved: true, TicketID: "ticket-notifier-err"}}
- notifier := newMockSlackNotifier(errors.New("slack down"))
+ notifier := newMockApprovalNotifier(errors.New("lark down"))
handler := newPolicyGateHandler(
&corepolicy.AgentPolicy{Budgets: corepolicy.Budgets{MaxToolCallsPerTurn: 3}},
NewBudgetTracker(),
@@ -778,9 +778,9 @@ func TestPolicyGateHandlerApprovalHoldNotifierErrorDoesNotBlockBridge(t *testing
t.Fatal("notifier.SendApprovalRequest was not called within 1 second")
}
deadline := time.Now().Add(time.Second)
- for !strings.Contains(buf.String(), "slack notification failed") {
+ for !strings.Contains(buf.String(), "lark notification failed") {
if time.Now().After(deadline) {
- t.Fatalf("logs = %q, want slack notification failure entry", buf.String())
+ t.Fatalf("logs = %q, want lark notification failure entry", buf.String())
}
time.Sleep(10 * time.Millisecond)
}
diff --git a/cmd/gateway/server.go b/cmd/gateway/server.go
index 3b68f9a..ff6d3fd 100644
--- a/cmd/gateway/server.go
+++ b/cmd/gateway/server.go
@@ -30,7 +30,7 @@ type Server struct {
pipeline *mcp.Pipeline
forwarder mcp.Handler
guard *ConcurrencyGuard
- slackWebhook http.Handler
+ webhookHandler http.Handler
sessions *SessionRegistry
mux *http.ServeMux
log *slog.Logger
@@ -56,10 +56,10 @@ func NewServer(config *Config, pipeline *mcp.Pipeline, log *slog.Logger) *Server
return server
}
-func (s *Server) SetSlackWebhookHandler(handler http.Handler) {
- s.slackWebhook = handler
+func (s *Server) SetWebhookHandler(handler http.Handler) {
+ s.webhookHandler = handler
if handler != nil {
- s.mux.Handle("POST /slack/actions", handler)
+ s.mux.Handle("POST /lark/actions", handler)
}
}
diff --git a/cmd/gateway/server_test.go b/cmd/gateway/server_test.go
index 2aaf0ef..1f62eec 100644
--- a/cmd/gateway/server_test.go
+++ b/cmd/gateway/server_test.go
@@ -369,6 +369,45 @@ func TestServerHTTPServerUsesApprovalSafeWriteTimeout(t *testing.T) {
}
}
+func TestServerUpstreamErrorWritesAuditRecordWhenAuditIsSet(t *testing.T) {
+ var written []AuditRecord
+ fakeAudit := auditRecorderFunc(func(r AuditRecord) { written = append(written, r) })
+
+ failForwarder := &captureHandler{err: fmt.Errorf("upstream down")}
+ config := &Config{
+ ListenPort: 8080,
+ UpstreamMCPURL: "http://example.invalid",
+ TurnIDHeader: defaultTurnIDHeader,
+ UpstreamTimeout: time.Second,
+ SessionTTL: time.Minute,
+ }
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ pipeline := mcp.NewPipeline(failForwarder)
+ server := NewServer(config, pipeline, logger)
+ server.forwarder = failForwarder
+ server.audit = fakeAudit
+
+ sessionID := server.sessions.Create().ID
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_charges","arguments":{}}}`))
+ req.Header.Set(mcpSessionIDHeader, sessionID)
+ server.ServeHTTP(rec, req)
+
+ if len(written) != 1 {
+ t.Fatalf("audit records written = %d, want 1", len(written))
+ }
+ if written[0].Decision != "upstream_error" {
+ t.Fatalf("audit decision = %q, want upstream_error", written[0].Decision)
+ }
+ if written[0].ToolName != "list_charges" {
+ t.Fatalf("audit tool name = %q, want list_charges", written[0].ToolName)
+ }
+}
+
+type auditRecorderFunc func(AuditRecord)
+
+func (f auditRecorderFunc) Write(r AuditRecord) { f(r) }
+
type captureHandler struct {
callCount int
ctx context.Context
diff --git a/cmd/gateway/slack_notifier.go b/cmd/gateway/slack_notifier.go
index 28f51ff..ef3df61 100644
--- a/cmd/gateway/slack_notifier.go
+++ b/cmd/gateway/slack_notifier.go
@@ -5,167 +5,239 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"log/slog"
"net/http"
+ "time"
)
const (
- slackAPIBaseURL = "https://slack.com/api"
- slackArgsTruncateAt = 2000
- slackArgsTruncateMark = "... [truncated]"
+ larkAPIBaseURL = "https://open.feishu.cn/open-apis"
+ larkArgsTruncateAt = 2000
+ larkArgsTruncateMark = "... [truncated]"
)
-// SlackNotifier abstracts outbound approval notification.
-// v0 implements with Slack chat.postMessage; v1+ may add other channels.
-type SlackNotifier interface {
- // SendApprovalRequest sends a Block Kit message with Approve/Deny buttons.
+// ApprovalNotifier abstracts outbound approval notification.
+// Implemented by LarkClient; tests use a mock double.
+type ApprovalNotifier interface {
+ // SendApprovalRequest sends an interactive message with Approve/Deny buttons.
// ticketID is embedded in button values for routing on callback.
// Errors are non-fatal: the caller logs and continues the approval hold.
SendApprovalRequest(ctx context.Context, ticketID string, t TicketRecord) error
}
-// SlackClient sends Block Kit approval request messages via Slack chat.postMessage.
-type SlackClient struct {
- botToken string
- channel string
+// LarkClient sends interactive card approval request messages via Lark's messaging API.
+type LarkClient struct {
+ appID string
+ appSecret string
+ chatID string
httpClient *http.Client
log *slog.Logger
- apiBaseURL string // overridable for tests; defaults to slackAPIBaseURL
+ apiBaseURL string // overridable for tests; defaults to larkAPIBaseURL
}
-// NewSlackClient constructs a production-ready SlackClient.
-func NewSlackClient(botToken, channel, baseURL string, log *slog.Logger) *SlackClient {
- sc := newSlackClientWithHTTP(botToken, channel, &http.Client{}, log)
- sc.apiBaseURL = baseURL
- return sc
+// NewLarkClient constructs a production-ready LarkClient.
+func NewLarkClient(appID, appSecret, chatID, baseURL string, log *slog.Logger) *LarkClient {
+ return newLarkClientWithHTTP(appID, appSecret, chatID, baseURL, &http.Client{Timeout: 15 * time.Second}, log)
}
-// newSlackClientWithHTTP constructs a SlackClient with an injected HTTP client.
-// This is the internal constructor used by tests to inject a custom transport or
-// redirect requests to a test server.
-func newSlackClientWithHTTP(botToken, channel string, httpClient *http.Client, log *slog.Logger) *SlackClient {
+// newLarkClientWithHTTP constructs a LarkClient with an injected HTTP client (used in tests).
+func newLarkClientWithHTTP(appID, appSecret, chatID, baseURL string, httpClient *http.Client, log *slog.Logger) *LarkClient {
if log == nil {
log = slog.Default()
}
if httpClient == nil {
httpClient = &http.Client{}
}
- return &SlackClient{
- botToken: botToken,
- channel: channel,
+ if baseURL == "" {
+ baseURL = larkAPIBaseURL
+ }
+ return &LarkClient{
+ appID: appID,
+ appSecret: appSecret,
+ chatID: chatID,
httpClient: httpClient,
log: log,
- apiBaseURL: slackAPIBaseURL,
+ apiBaseURL: baseURL,
}
}
-// slackText is a Slack text object used inside blocks.
-type slackText struct {
- Type string `json:"type"`
- Text string `json:"text"`
+// larkTenantTokenReq is the payload for the tenant access token endpoint.
+type larkTenantTokenReq struct {
+ AppID string `json:"app_id"`
+ AppSecret string `json:"app_secret"`
}
-// slackSectionBlock is a Slack section block.
-type slackSectionBlock struct {
- Type string `json:"type"`
- Text slackText `json:"text"`
+// larkTenantTokenResp is the response from the tenant access token endpoint.
+type larkTenantTokenResp struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ TenantAccessToken string `json:"tenant_access_token"`
}
-// slackButtonElement is a Slack button element inside an actions block.
-type slackButtonElement struct {
- Type string `json:"type"`
- Text slackText `json:"text"`
- ActionID string `json:"action_id"`
- Value string `json:"value"`
+// larkCardText is a Lark card text element.
+type larkCardText struct {
+ Tag string `json:"tag"`
+ Content string `json:"content"`
}
-// slackActionsBlock is a Slack actions block containing interactive elements.
-type slackActionsBlock struct {
- Type string `json:"type"`
- Elements []slackButtonElement `json:"elements"`
+// larkCardButton is a Lark interactive card button element.
+type larkCardButton struct {
+ Tag string `json:"tag"`
+ Text larkCardText `json:"text"`
+ Type string `json:"type"`
+ Value map[string]string `json:"value"`
}
-// slackChatPostMessageRequest is the payload for Slack chat.postMessage.
-type slackChatPostMessageRequest struct {
- Channel string `json:"channel"`
- Blocks []interface{} `json:"blocks"`
+// larkCardAction is a Lark card action block containing buttons.
+type larkCardAction struct {
+ Tag string `json:"tag"`
+ Actions []larkCardButton `json:"actions"`
}
-// SendApprovalRequest sends a Block Kit message to Slack containing the tool details
-// and Approve/Deny action buttons. The ticketID is embedded in each button's value
-// so the webhook handler can route the decision back to the correct approval hold.
-func (c *SlackClient) SendApprovalRequest(ctx context.Context, ticketID string, t TicketRecord) error {
- argsStr := truncateArgs(t.Arguments)
+// larkCardDiv is a Lark card markdown text block.
+type larkCardDiv struct {
+ Tag string `json:"tag"`
+ Text larkCardText `json:"text"`
+}
- sectionText := fmt.Sprintf(
- "*Tool:* %s\n*Arguments:* %s\n*Session:* %s",
- t.ToolName,
- argsStr,
- t.SessionID,
- )
-
- payload := slackChatPostMessageRequest{
- Channel: c.channel,
- Blocks: []interface{}{
- slackSectionBlock{
- Type: "section",
- Text: slackText{
- Type: "mrkdwn",
- Text: sectionText,
- },
+// larkCard is the top-level interactive card payload.
+type larkCard struct {
+ Config map[string]bool `json:"config"`
+ Elements []interface{} `json:"elements"`
+}
+
+// larkSendMessageReq is the payload for Lark's im/v1/messages endpoint.
+// Content is the JSON-encoded card string (Lark requires a JSON string, not object).
+type larkSendMessageReq struct {
+ ReceiveID string `json:"receive_id"`
+ MsgType string `json:"msg_type"`
+ Content string `json:"content"`
+}
+
+// fetchTenantToken obtains a short-lived tenant access token using app credentials.
+func (c *LarkClient) fetchTenantToken(ctx context.Context) (string, error) {
+ body, err := json.Marshal(larkTenantTokenReq{AppID: c.appID, AppSecret: c.appSecret})
+ if err != nil {
+ return "", fmt.Errorf("lark notifier: marshal token request: %w", err)
+ }
+
+ url := c.apiBaseURL + "/auth/v3/tenant_access_token/internal"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return "", fmt.Errorf("lark notifier: build token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("lark notifier: token http request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("lark notifier: token endpoint status %d", resp.StatusCode)
+ }
+
+ var tokenResp larkTenantTokenResp
+ rawBody, _ := io.ReadAll(resp.Body)
+ if err := json.Unmarshal(rawBody, &tokenResp); err != nil {
+ return "", fmt.Errorf("lark notifier: decode token response: %w", err)
+ }
+ if tokenResp.Code != 0 {
+ return "", fmt.Errorf("lark notifier: token error code %d: %s", tokenResp.Code, tokenResp.Msg)
+ }
+ return tokenResp.TenantAccessToken, nil
+}
+
+// SendApprovalRequest sends an interactive Lark card with Approve/Deny buttons.
+// The ticketID and action are embedded in each button's value map so the webhook
+// handler can route the decision back to the correct approval hold.
+func (c *LarkClient) SendApprovalRequest(ctx context.Context, ticketID string, t TicketRecord) error {
+ token, err := c.fetchTenantToken(ctx)
+ if err != nil {
+ return err
+ }
+
+ argsStr := truncateArgs(t.Arguments)
+ cardText := fmt.Sprintf("**Tool:** %s\n**Arguments:** %s\n**Session:** %s",
+ t.ToolName, argsStr, t.SessionID)
+
+ card := larkCard{
+ Config: map[string]bool{"wide_screen_mode": true},
+ Elements: []interface{}{
+ larkCardDiv{
+ Tag: "div",
+ Text: larkCardText{Tag: "lark_md", Content: cardText},
},
- slackActionsBlock{
- Type: "actions",
- Elements: []slackButtonElement{
+ larkCardAction{
+ Tag: "action",
+ Actions: []larkCardButton{
{
- Type: "button",
- Text: slackText{Type: "plain_text", Text: "Approve"},
- ActionID: "approval_approve",
- Value: ticketID,
+ Tag: "button",
+ Text: larkCardText{Tag: "plain_text", Content: "Approve"},
+ Type: "primary",
+ Value: map[string]string{
+ "ticket_id": ticketID,
+ "action": "approve",
+ },
},
{
- Type: "button",
- Text: slackText{Type: "plain_text", Text: "Deny"},
- ActionID: "approval_deny",
- Value: ticketID,
+ Tag: "button",
+ Text: larkCardText{Tag: "plain_text", Content: "Deny"},
+ Type: "danger",
+ Value: map[string]string{
+ "ticket_id": ticketID,
+ "action": "deny",
+ },
},
},
},
},
}
- body, err := json.Marshal(payload)
+ cardJSON, err := json.Marshal(card)
if err != nil {
- return fmt.Errorf("slack notifier: marshal payload: %w", err)
+ return fmt.Errorf("lark notifier: marshal card: %w", err)
}
- url := c.apiBaseURL + "/chat.postMessage"
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ msgBody, err := json.Marshal(larkSendMessageReq{
+ ReceiveID: c.chatID,
+ MsgType: "interactive",
+ Content: string(cardJSON),
+ })
if err != nil {
- return fmt.Errorf("slack notifier: build request: %w", err)
+ return fmt.Errorf("lark notifier: marshal message request: %w", err)
}
- req.Header.Set("Authorization", "Bearer "+c.botToken)
+
+ url := c.apiBaseURL + "/im/v1/messages?receive_id_type=chat_id"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(msgBody))
+ if err != nil {
+ return fmt.Errorf("lark notifier: build message request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
- return fmt.Errorf("slack notifier: http request: %w", err)
+ return fmt.Errorf("lark notifier: message http request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("slack notifier: unexpected status %d", resp.StatusCode)
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("lark notifier: unexpected status %d: %s", resp.StatusCode, body)
}
-
+ c.log.Info("lark approval card sent", "ticketID", ticketID, "chatID", c.chatID)
return nil
}
-// truncateArgs converts the raw arguments JSON to a displayable string,
-// truncating at slackArgsTruncateAt characters to stay within Slack's per-block limits.
+// truncateArgs converts raw arguments JSON to a displayable string,
+// truncating at larkArgsTruncateAt characters to stay within card limits.
func truncateArgs(args json.RawMessage) string {
s := string(args)
- if len(s) > slackArgsTruncateAt {
- return s[:slackArgsTruncateAt] + slackArgsTruncateMark
+ if len(s) > larkArgsTruncateAt {
+ return s[:larkArgsTruncateAt] + larkArgsTruncateMark
}
return s
}
diff --git a/cmd/gateway/slack_notifier_test.go b/cmd/gateway/slack_notifier_test.go
index 7c11f2a..59a1f41 100644
--- a/cmd/gateway/slack_notifier_test.go
+++ b/cmd/gateway/slack_notifier_test.go
@@ -13,35 +13,45 @@ import (
// --- Helpers ---
-// capturedSlackRequest holds the decoded request captured by the test server.
-type capturedSlackRequest struct {
- authHeader string
- body []byte
+// capturedLarkRequests holds requests captured by the two-endpoint test server.
+type capturedLarkRequests struct {
+ tokenBody []byte
+ msgAuth string
+ msgBody []byte
}
-func newSlackTestServer(t *testing.T, statusCode int) (*httptest.Server, *capturedSlackRequest) {
+// newLarkTestServer creates a test server that handles both the token endpoint and
+// the message endpoint, capturing requests for assertion.
+func newLarkTestServer(t *testing.T, msgStatusCode int) (*httptest.Server, *capturedLarkRequests) {
t.Helper()
- cap := &capturedSlackRequest{}
+ cap := &capturedLarkRequests{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- cap.authHeader = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
- cap.body = body
- w.WriteHeader(statusCode)
- if statusCode == http.StatusOK {
- // Minimal Slack API success response
- _, _ = w.Write([]byte(`{"ok":true}`))
+ switch r.URL.Path {
+ case "/auth/v3/tenant_access_token/internal":
+ cap.tokenBody = body
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"mock-token","expire":7200}`))
+ case "/im/v1/messages":
+ cap.msgAuth = r.Header.Get("Authorization")
+ cap.msgBody = body
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(msgStatusCode)
+ if msgStatusCode == http.StatusOK {
+ _, _ = w.Write([]byte(`{"code":0}`))
+ }
+ default:
+ http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv, cap
}
-func newTestSlackClient(t *testing.T, serverURL, botToken, channel string) *SlackClient {
+func newTestLarkClient(t *testing.T, serverURL, appID, appSecret, chatID string) *LarkClient {
t.Helper()
- log := slog.Default()
- client := newSlackClientWithHTTP(botToken, channel, &http.Client{}, log)
- client.apiBaseURL = serverURL
- return client
+ return newLarkClientWithHTTP(appID, appSecret, chatID, serverURL, &http.Client{}, slog.Default())
}
func sampleTicketRecord() TicketRecord {
@@ -56,288 +66,264 @@ func sampleTicketRecord() TicketRecord {
// --- Tests ---
-// TestSlackClientSendsCorrectActionIDs verifies that the Block Kit message includes
-// action_id values "approval_approve" and "approval_deny" on the buttons.
-func TestSlackClientSendsCorrectActionIDs(t *testing.T) {
+// TestLarkClientFetchesTokenBeforeSendingMessage verifies the token endpoint is called
+// and the resulting token is used in the Authorization header.
+func TestLarkClientFetchesTokenBeforeSendingMessage(t *testing.T) {
t.Parallel()
- srv, cap := newSlackTestServer(t, http.StatusOK)
- client := newTestSlackClient(t, srv.URL, "xoxb-test-token", "C12345")
+ srv, cap := newLarkTestServer(t, http.StatusOK)
+ client := newTestLarkClient(t, srv.URL, "cli_app", "app_secret", "oc_chat")
ticket := sampleTicketRecord()
- ticketID := "ticket-001"
- if err := client.SendApprovalRequest(t.Context(), ticketID, ticket); err != nil {
+ if err := client.SendApprovalRequest(t.Context(), "ticket-001", ticket); err != nil {
t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
}
- // Parse the sent body to inspect action IDs
- var payload map[string]interface{}
- if err := json.Unmarshal(cap.body, &payload); err != nil {
- t.Fatalf("could not parse request body: %v\nbody: %s", err, string(cap.body))
- }
-
- blocks, ok := payload["blocks"].([]interface{})
- if !ok || len(blocks) < 2 {
- t.Fatalf("expected at least 2 blocks, got: %v", payload["blocks"])
- }
-
- actionsBlock, ok := blocks[1].(map[string]interface{})
- if !ok {
- t.Fatalf("blocks[1] is not an object: %T", blocks[1])
- }
- if actionsBlock["type"] != "actions" {
- t.Fatalf("blocks[1].type = %q, want %q", actionsBlock["type"], "actions")
- }
-
- elements, ok := actionsBlock["elements"].([]interface{})
- if !ok || len(elements) < 2 {
- t.Fatalf("expected 2 button elements, got: %v", actionsBlock["elements"])
- }
-
- approveBtn, ok := elements[0].(map[string]interface{})
- if !ok {
- t.Fatal("approve button is not a map")
+ if cap.tokenBody == nil {
+ t.Fatal("token endpoint was not called")
}
- if approveBtn["action_id"] != "approval_approve" {
- t.Errorf("approve button action_id = %q, want %q", approveBtn["action_id"], "approval_approve")
+ var tokenReq map[string]string
+ if err := json.Unmarshal(cap.tokenBody, &tokenReq); err != nil {
+ t.Fatalf("parse token request body: %v", err)
}
-
- denyBtn, ok := elements[1].(map[string]interface{})
- if !ok {
- t.Fatal("deny button is not a map")
+ if tokenReq["app_id"] != "cli_app" {
+ t.Errorf("token request app_id = %q, want %q", tokenReq["app_id"], "cli_app")
}
- if denyBtn["action_id"] != "approval_deny" {
- t.Errorf("deny button action_id = %q, want %q", denyBtn["action_id"], "approval_deny")
+ if cap.msgAuth != "Bearer mock-token" {
+ t.Errorf("message Authorization = %q, want %q", cap.msgAuth, "Bearer mock-token")
}
}
-// TestSlackClientButtonValueIsTicketID verifies that both buttons carry the ticket ID as value.
-func TestSlackClientButtonValueIsTicketID(t *testing.T) {
+// TestLarkClientSendsCorrectChatID verifies receive_id in the message payload equals the chatID.
+func TestLarkClientSendsCorrectChatID(t *testing.T) {
t.Parallel()
- srv, cap := newSlackTestServer(t, http.StatusOK)
- client := newTestSlackClient(t, srv.URL, "xoxb-test-token", "C12345")
+ srv, cap := newLarkTestServer(t, http.StatusOK)
+ chatID := "oc_my_channel"
+ client := newTestLarkClient(t, srv.URL, "cli_app", "secret", chatID)
ticket := sampleTicketRecord()
- ticketID := "ticket-val-002"
- if err := client.SendApprovalRequest(t.Context(), ticketID, ticket); err != nil {
+ if err := client.SendApprovalRequest(t.Context(), "ticket-002", ticket); err != nil {
t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
}
var payload map[string]interface{}
- if err := json.Unmarshal(cap.body, &payload); err != nil {
- t.Fatalf("could not parse request body: %v", err)
+ if err := json.Unmarshal(cap.msgBody, &payload); err != nil {
+ t.Fatalf("parse message body: %v", err)
}
-
- blocks := payload["blocks"].([]interface{})
- actionsBlock := blocks[1].(map[string]interface{})
- elements := actionsBlock["elements"].([]interface{})
-
- for i, elem := range elements {
- btn := elem.(map[string]interface{})
- if btn["value"] != ticketID {
- t.Errorf("button[%d].value = %q, want %q", i, btn["value"], ticketID)
- }
+ if payload["receive_id"] != chatID {
+ t.Errorf("receive_id = %q, want %q", payload["receive_id"], chatID)
+ }
+ if payload["msg_type"] != "interactive" {
+ t.Errorf("msg_type = %q, want %q", payload["msg_type"], "interactive")
}
}
-// TestSlackClientSendsAuthorizationHeader verifies the Authorization: Bearer header.
-func TestSlackClientSendsAuthorizationHeader(t *testing.T) {
+// TestLarkClientCardContainsToolDetails verifies the card content includes tool, args, session.
+func TestLarkClientCardContainsToolDetails(t *testing.T) {
t.Parallel()
- srv, cap := newSlackTestServer(t, http.StatusOK)
- botToken := "xoxb-my-secret-token"
- client := newTestSlackClient(t, srv.URL, botToken, "C12345")
+ srv, cap := newLarkTestServer(t, http.StatusOK)
+ client := newTestLarkClient(t, srv.URL, "cli_app", "secret", "oc_chat")
ticket := sampleTicketRecord()
+ ticketID := "ticket-003"
- if err := client.SendApprovalRequest(t.Context(), "ticket-hdr-003", ticket); err != nil {
+ if err := client.SendApprovalRequest(t.Context(), ticketID, ticket); err != nil {
t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
}
- want := "Bearer " + botToken
- if cap.authHeader != want {
- t.Errorf("Authorization header = %q, want %q", cap.authHeader, want)
- }
-}
-
-// TestSlackClientNon200ReturnsWrappedError verifies that a non-200 response from Slack
-// results in a wrapped error being returned.
-func TestSlackClientNon200ReturnsWrappedError(t *testing.T) {
- t.Parallel()
-
- srv, _ := newSlackTestServer(t, http.StatusInternalServerError)
- client := newTestSlackClient(t, srv.URL, "xoxb-test-token", "C12345")
- ticket := sampleTicketRecord()
-
- err := client.SendApprovalRequest(t.Context(), "ticket-err-004", ticket)
- if err == nil {
- t.Fatal("SendApprovalRequest() error = nil, want wrapped error for non-200 status")
+ // The content field is a JSON string containing the card JSON.
+ var msgPayload map[string]interface{}
+ if err := json.Unmarshal(cap.msgBody, &msgPayload); err != nil {
+ t.Fatalf("parse message body: %v", err)
}
-}
-
-// TestSlackClientTruncatesLongArguments verifies that arguments > 2000 chars are truncated
-// so the Slack block text stays within limits.
-func TestSlackClientTruncatesLongArguments(t *testing.T) {
- t.Parallel()
-
- srv, cap := newSlackTestServer(t, http.StatusOK)
- client := newTestSlackClient(t, srv.URL, "xoxb-test-token", "C12345")
-
- // Build a very large arguments JSON value (> 3000 chars)
- longArgs := `{"command":"` + strings.Repeat("a", 3100) + `"}`
- ticket := TicketRecord{
- SessionID: "sess-trunc",
- TurnID: "turn-trunc",
- ToolName: "bash",
- Arguments: json.RawMessage(longArgs),
- ExpiresAt: time.Now().Add(5 * time.Minute),
+ contentStr, ok := msgPayload["content"].(string)
+ if !ok {
+ t.Fatalf("content field is not a string: %T", msgPayload["content"])
}
- if err := client.SendApprovalRequest(t.Context(), "ticket-trunc-005", ticket); err != nil {
- t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
+ var card map[string]interface{}
+ if err := json.Unmarshal([]byte(contentStr), &card); err != nil {
+ t.Fatalf("parse card JSON: %v", err)
}
- var payload map[string]interface{}
- if err := json.Unmarshal(cap.body, &payload); err != nil {
- t.Fatalf("could not parse request body: %v", err)
+ elements, ok := card["elements"].([]interface{})
+ if !ok || len(elements) < 2 {
+ t.Fatalf("expected at least 2 card elements, got: %v", card["elements"])
}
- blocks := payload["blocks"].([]interface{})
- sectionBlock, ok := blocks[0].(map[string]interface{})
- if !ok {
- t.Fatal("blocks[0] is not an object")
- }
- textObj, ok := sectionBlock["text"].(map[string]interface{})
+ divBlock, ok := elements[0].(map[string]interface{})
if !ok {
- t.Fatal("blocks[0].text is not an object")
+ t.Fatalf("elements[0] is not an object: %T", elements[0])
}
- text, ok := textObj["text"].(string)
+ textObj, ok := divBlock["text"].(map[string]interface{})
if !ok {
- t.Fatal("blocks[0].text.text is not a string")
+ t.Fatalf("elements[0].text is not an object: %T", divBlock["text"])
}
+ textContent, _ := textObj["content"].(string)
- // The raw long args string should NOT appear verbatim; total text should be well under Slack's 3000-char limit
- if len(text) > 3000 {
- t.Errorf("section text length = %d, want <= 3000 chars (Slack block limit)", len(text))
+ for _, want := range []string{ticket.ToolName, "ls -la", ticket.SessionID} {
+ if !strings.Contains(textContent, want) {
+ t.Errorf("card text missing %q\ntext: %s", want, textContent)
+ }
}
}
-// TestSlackClientSectionBlockContainsToolDetails verifies the section block includes
-// tool name, arguments, and session ID — and does NOT duplicate ToolName on a
-// separate *Operation:* line.
-func TestSlackClientSectionBlockContainsToolDetails(t *testing.T) {
+// TestLarkClientButtonValuesContainTicketID verifies Approve/Deny buttons embed the ticketID.
+func TestLarkClientButtonValuesContainTicketID(t *testing.T) {
t.Parallel()
- srv, cap := newSlackTestServer(t, http.StatusOK)
- client := newTestSlackClient(t, srv.URL, "xoxb-test-token", "C12345")
+ srv, cap := newLarkTestServer(t, http.StatusOK)
+ client := newTestLarkClient(t, srv.URL, "cli_app", "secret", "oc_chat")
ticket := sampleTicketRecord()
- ticketID := "ticket-section-006"
+ ticketID := "ticket-val-004"
if err := client.SendApprovalRequest(t.Context(), ticketID, ticket); err != nil {
t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
}
- var payload map[string]interface{}
- if err := json.Unmarshal(cap.body, &payload); err != nil {
- t.Fatalf("could not parse request body: %v", err)
- }
+ var msgPayload map[string]interface{}
+ _ = json.Unmarshal(cap.msgBody, &msgPayload)
+ contentStr, _ := msgPayload["content"].(string)
+ var card map[string]interface{}
+ _ = json.Unmarshal([]byte(contentStr), &card)
+ elements := card["elements"].([]interface{})
+ actionBlock := elements[1].(map[string]interface{})
+ actions := actionBlock["actions"].([]interface{})
- blocks := payload["blocks"].([]interface{})
- sectionBlock := blocks[0].(map[string]interface{})
- if sectionBlock["type"] != "section" {
- t.Errorf("blocks[0].type = %q, want %q", sectionBlock["type"], "section")
+ if len(actions) < 2 {
+ t.Fatalf("expected 2 buttons, got %d", len(actions))
}
-
- textObj := sectionBlock["text"].(map[string]interface{})
- if textObj["type"] != "mrkdwn" {
- t.Errorf("blocks[0].text.type = %q, want %q", textObj["type"], "mrkdwn")
+ for i, a := range actions {
+ btn := a.(map[string]interface{})
+ value, _ := btn["value"].(map[string]interface{})
+ if value["ticket_id"] != ticketID {
+ t.Errorf("button[%d].value.ticket_id = %q, want %q", i, value["ticket_id"], ticketID)
+ }
}
+}
- text := textObj["text"].(string)
+// TestLarkClientButtonActionsAreApproveAndDeny verifies button values carry correct action names.
+func TestLarkClientButtonActionsAreApproveAndDeny(t *testing.T) {
+ t.Parallel()
- // Tool name, arguments, and session ID must all appear.
- checks := []struct {
- field string
- value string
- }{
- {"ToolName", ticket.ToolName},
- {"Arguments", `"command":"ls -la"`},
- {"SessionID", ticket.SessionID},
- }
- for _, c := range checks {
- if !strings.Contains(text, c.value) {
- t.Errorf("section text missing %s %q\ntext: %s", c.field, c.value, text)
- }
+ srv, cap := newLarkTestServer(t, http.StatusOK)
+ client := newTestLarkClient(t, srv.URL, "cli_app", "secret", "oc_chat")
+ ticket := sampleTicketRecord()
+
+ if err := client.SendApprovalRequest(t.Context(), "ticket-005", ticket); err != nil {
+ t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
}
- // The *Operation:* label must not appear — ToolName already conveys the
- // operation; a separate *Operation:* line would only duplicate it.
- if strings.Contains(text, "*Operation:*") {
- t.Errorf("section text contains redundant *Operation:* label\ntext: %s", text)
+ var msgPayload map[string]interface{}
+ _ = json.Unmarshal(cap.msgBody, &msgPayload)
+ contentStr, _ := msgPayload["content"].(string)
+ var card map[string]interface{}
+ _ = json.Unmarshal([]byte(contentStr), &card)
+ elements := card["elements"].([]interface{})
+ actionBlock := elements[1].(map[string]interface{})
+ actions := actionBlock["actions"].([]interface{})
+
+ approve := actions[0].(map[string]interface{})
+ approveValue, _ := approve["value"].(map[string]interface{})
+ if approveValue["action"] != "approve" {
+ t.Errorf("button[0].value.action = %q, want %q", approveValue["action"], "approve")
}
- // ToolName should appear exactly once (under the *Tool:* label).
- if count := strings.Count(text, ticket.ToolName); count != 1 {
- t.Errorf("ToolName %q appears %d time(s) in section text, want exactly 1\ntext: %s",
- ticket.ToolName, count, text)
+ deny := actions[1].(map[string]interface{})
+ denyValue, _ := deny["value"].(map[string]interface{})
+ if denyValue["action"] != "deny" {
+ t.Errorf("button[1].value.action = %q, want %q", denyValue["action"], "deny")
}
}
-// TestSlackClientSendsToConfiguredChannel verifies the channel field in the payload.
-func TestSlackClientSendsToConfiguredChannel(t *testing.T) {
+// TestLarkClientNon200MessageResponseReturnsError verifies a non-200 from the message endpoint.
+func TestLarkClientNon200MessageResponseReturnsError(t *testing.T) {
t.Parallel()
- srv, cap := newSlackTestServer(t, http.StatusOK)
- channel := "C-MY-CHANNEL"
- client := newTestSlackClient(t, srv.URL, "xoxb-test-token", channel)
- ticket := sampleTicketRecord()
+ srv, _ := newLarkTestServer(t, http.StatusInternalServerError)
+ client := newTestLarkClient(t, srv.URL, "cli_app", "secret", "oc_chat")
- if err := client.SendApprovalRequest(t.Context(), "ticket-ch-007", ticket); err != nil {
- t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
+ err := client.SendApprovalRequest(t.Context(), "ticket-006", sampleTicketRecord())
+ if err == nil {
+ t.Fatal("SendApprovalRequest() error = nil, want error for non-200 message response")
}
+}
- var payload map[string]interface{}
- if err := json.Unmarshal(cap.body, &payload); err != nil {
- t.Fatalf("could not parse request body: %v", err)
- }
+// TestLarkClientTokenFetchFailureReturnsError verifies token endpoint failure propagates.
+func TestLarkClientTokenFetchFailureReturnsError(t *testing.T) {
+ t.Parallel()
+
+ // Server that returns a non-200 on token endpoint.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ t.Cleanup(srv.Close)
- if payload["channel"] != channel {
- t.Errorf("payload.channel = %q, want %q", payload["channel"], channel)
+ client := newLarkClientWithHTTP("bad_id", "bad_secret", "oc_chat", srv.URL, &http.Client{}, slog.Default())
+ err := client.SendApprovalRequest(t.Context(), "ticket-007", sampleTicketRecord())
+ if err == nil {
+ t.Fatal("SendApprovalRequest() error = nil, want error for token fetch failure")
}
}
-// TestSlackClientHTTPClientError verifies that a failed HTTP request returns an error.
-func TestSlackClientHTTPClientError(t *testing.T) {
+// TestLarkClientTruncatesLongArguments verifies args longer than the limit are truncated.
+func TestLarkClientTruncatesLongArguments(t *testing.T) {
t.Parallel()
- // Use an invalid URL that will fail to connect
- log := slog.Default()
- client := newSlackClientWithHTTP("xoxb-token", "C12345", &http.Client{}, log)
- client.apiBaseURL = "http://127.0.0.1:0" // No listener — connection refused
+ srv, cap := newLarkTestServer(t, http.StatusOK)
+ client := newTestLarkClient(t, srv.URL, "cli_app", "secret", "oc_chat")
- ticket := sampleTicketRecord()
- err := client.SendApprovalRequest(t.Context(), "ticket-httperr-008", ticket)
- if err == nil {
- t.Fatal("SendApprovalRequest() error = nil, want error for failed HTTP request")
+ longArgs := `{"command":"` + strings.Repeat("a", 3100) + `"}`
+ ticket := TicketRecord{
+ SessionID: "sess-trunc",
+ TurnID: "turn-trunc",
+ ToolName: "bash",
+ Arguments: json.RawMessage(longArgs),
+ ExpiresAt: time.Now().Add(5 * time.Minute),
+ }
+
+ if err := client.SendApprovalRequest(t.Context(), "ticket-008", ticket); err != nil {
+ t.Fatalf("SendApprovalRequest() error = %v, want nil", err)
+ }
+
+ var msgPayload map[string]interface{}
+ _ = json.Unmarshal(cap.msgBody, &msgPayload)
+ contentStr, _ := msgPayload["content"].(string)
+ if strings.Contains(contentStr, strings.Repeat("a", 3100)) {
+ t.Error("card content contains untruncated long argument, want truncated")
+ }
+ if !strings.Contains(contentStr, larkArgsTruncateMark) {
+ t.Error("card content missing truncation mark")
}
}
-// TestNewSlackClientReturnsSensibleDefaults ensures NewSlackClient sets botToken and channel.
-func TestNewSlackClientReturnsSensibleDefaults(t *testing.T) {
+// TestNewLarkClientStoresCredentials verifies the constructor stores credentials correctly.
+func TestNewLarkClientStoresCredentials(t *testing.T) {
t.Parallel()
- botToken := "xoxb-new-client"
- channel := "C-NEW"
- log := slog.Default()
- client := NewSlackClient(botToken, channel, slackAPIBaseURL, log)
-
- if client.botToken != botToken {
- t.Errorf("client.botToken = %q, want %q", client.botToken, botToken)
+ client := NewLarkClient("cli_my_app", "my_secret", "oc_my_chat", larkAPIBaseURL, slog.Default())
+ if client.appID != "cli_my_app" {
+ t.Errorf("appID = %q, want %q", client.appID, "cli_my_app")
+ }
+ if client.appSecret != "my_secret" {
+ t.Errorf("appSecret = %q, want %q", client.appSecret, "my_secret")
}
- if client.channel != channel {
- t.Errorf("client.channel = %q, want %q", client.channel, channel)
+ if client.chatID != "oc_my_chat" {
+ t.Errorf("chatID = %q, want %q", client.chatID, "oc_my_chat")
}
if client.httpClient == nil {
- t.Error("client.httpClient = nil, want a default *http.Client")
+ t.Error("httpClient = nil, want a default *http.Client")
+ }
+}
+
+// TestLarkClientHTTPClientError verifies that a connection failure returns a wrapped error.
+func TestLarkClientHTTPClientError(t *testing.T) {
+ t.Parallel()
+
+ client := newLarkClientWithHTTP("id", "secret", "chat", "http://127.0.0.1:0", &http.Client{}, slog.Default())
+ err := client.SendApprovalRequest(t.Context(), "ticket-err-009", sampleTicketRecord())
+ if err == nil {
+ t.Fatal("SendApprovalRequest() error = nil, want error for connection refused")
}
}
diff --git a/cmd/gateway/slack_webhook.go b/cmd/gateway/slack_webhook.go
index fdf929a..4c9dda3 100644
--- a/cmd/gateway/slack_webhook.go
+++ b/cmd/gateway/slack_webhook.go
@@ -2,14 +2,12 @@ package main
import (
"context"
- "crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
- "net/url"
"strconv"
"time"
@@ -31,24 +29,23 @@ func (r *realRedisPublisher) Publish(ctx context.Context, channel string, messag
return r.client.Publish(ctx, channel, message).Err()
}
-// SlackWebhookHandler handles POST /slack/actions requests from Slack.
-// It verifies the HMAC-SHA256 signature, parses the action payload,
+// LarkWebhookHandler handles POST /lark/actions requests from Lark.
+// It verifies the request signature, parses the card action payload,
// updates the ticket status, and publishes a resume signal.
-type SlackWebhookHandler struct {
- signingSecret string
- tickets ticketStatusUpdater
- redis redisPublisher
- log *slog.Logger
+type LarkWebhookHandler struct {
+ verificationToken string
+ tickets ticketStatusUpdater
+ redis redisPublisher
+ log *slog.Logger
}
-// NewSlackWebhookHandler constructs a production-ready SlackWebhookHandler.
-// It accepts the concrete *TicketStore and *redis.Client types as specified in design.md.
-func NewSlackWebhookHandler(
- signingSecret string,
+// NewLarkWebhookHandler constructs a production-ready LarkWebhookHandler.
+func NewLarkWebhookHandler(
+ verificationToken string,
tickets *TicketStore,
rdb *redis.Client,
log *slog.Logger,
-) *SlackWebhookHandler {
+) *LarkWebhookHandler {
if log == nil {
log = slog.Default()
}
@@ -60,164 +57,211 @@ func NewSlackWebhookHandler(
if tickets != nil {
ts = tickets
}
- return &SlackWebhookHandler{
- signingSecret: signingSecret,
- tickets: ts,
- redis: pub,
- log: log,
+ return &LarkWebhookHandler{
+ verificationToken: verificationToken,
+ tickets: ts,
+ redis: pub,
+ log: log,
}
}
-// newSlackWebhookHandlerWithDeps constructs a SlackWebhookHandler with injected
+// newLarkWebhookHandlerWithDeps constructs a LarkWebhookHandler with injected
// interface dependencies — used in tests to inject mocks.
-func newSlackWebhookHandlerWithDeps(
- signingSecret string,
+func newLarkWebhookHandlerWithDeps(
+ verificationToken string,
tickets ticketStatusUpdater,
redis redisPublisher,
log *slog.Logger,
-) *SlackWebhookHandler {
+) *LarkWebhookHandler {
if log == nil {
log = slog.Default()
}
- return &SlackWebhookHandler{
- signingSecret: signingSecret,
- tickets: tickets,
- redis: redis,
- log: log,
+ return &LarkWebhookHandler{
+ verificationToken: verificationToken,
+ tickets: tickets,
+ redis: redis,
+ log: log,
}
}
-// slackBlockActionsPayload is the internal representation of a Slack block_actions payload.
-type slackBlockActionsPayload struct {
- Type string `json:"type"`
- User slackUser `json:"user"`
- Actions []slackAction `json:"actions"`
+// larkActionValue carries the ticket_id and action from a button click.
+type larkActionValue struct {
+ TicketID string `json:"ticket_id"`
+ Action string `json:"action"`
}
-// slackUser carries the Slack user ID from the action callback.
-type slackUser struct {
- ID string `json:"id"`
+// larkCardAction carries the button action data in a card callback.
+type larkCardCallbackAction struct {
+ Tag string `json:"tag"`
+ Value larkActionValue `json:"value"`
}
-// slackAction represents a single interactive component action.
-type slackAction struct {
- ActionID string `json:"action_id"`
- Value string `json:"value"`
+// larkCallbackEnvelope unmarshals both the old flat v1 payload and the nested
+// schema:"2.0" v2 payload that Lark now sends for card.action.trigger events.
+//
+// v1 (flat): { "token": "...", "open_id": "...", "action": {...} }
+// v2 (nested): { "schema":"2.0", "header":{"token":"..."}, "event":{"operator":{"open_id":"..."}, "action":{...}} }
+type larkCallbackEnvelope struct {
+ Schema string `json:"schema"`
+ // v2 fields
+ Header struct {
+ Token string `json:"token"`
+ } `json:"header"`
+ Event struct {
+ Operator struct {
+ OpenID string `json:"open_id"`
+ } `json:"operator"`
+ Action larkCardCallbackAction `json:"action"`
+ } `json:"event"`
+ // v1 flat fields
+ Token string `json:"token"`
+ OpenID string `json:"open_id"`
+ Action larkCardCallbackAction `json:"action"`
}
-const slackReplayWindowSeconds = 5 * 60 // 5 minutes
+const larkReplayWindowSeconds = 5 * 60 // 5 minutes
-// ServeHTTP processes POST /slack/actions requests.
-// Processing order is NON-NEGOTIABLE for security (design.md):
-// 1. Read raw body (must precede any parsing for HMAC)
+// computeLarkSignature returns hex(sha256(verificationToken + timestamp + nonce + body)).
+// Both the gateway and mock-lark use this formula so signatures are mutually verifiable.
+func computeLarkSignature(verificationToken, timestamp, nonce string, body []byte) string {
+ h := sha256.New()
+ h.Write([]byte(verificationToken))
+ h.Write([]byte(timestamp))
+ h.Write([]byte(nonce))
+ h.Write(body)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// ServeHTTP processes POST /lark/actions requests.
+// Processing order (non-negotiable for security):
+// 0. Handle Lark URL verification challenge (no signature required — used during setup)
+// 1. Read raw body (must precede any parsing for signature check)
// 2. Check timestamp replay window
-// 3. Verify HMAC-SHA256 signature
-// 4. Parse URL-encoded payload field → unmarshal JSON
-// 5. Route on action_id
+// 3. Verify SHA-256 signature
+// 4. Parse JSON payload
+// 5. Route on action value
// 6. Extract ticketID
// 7. UpdateStatus → on error return 500
// 8. Publish resume signal → on error log warning, continue
// 9. Return 200
-func (h *SlackWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (h *LarkWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- // Step 1: Read raw request body into []byte BEFORE any parsing (required for HMAC).
+ // Step 1: Read raw body before any parsing (required for signature verification).
rawBody, err := io.ReadAll(r.Body)
if err != nil {
- h.log.Error("slack webhook: read body failed", "error", err)
+ h.log.Error("lark webhook: read body failed", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
- // Step 2: Extract and validate timestamp (replay attack prevention, req 3.4).
- tsHeader := r.Header.Get("X-Slack-Request-Timestamp")
- tsUnix, err := strconv.ParseInt(tsHeader, 10, 64)
- if err != nil {
- h.log.Warn("slack webhook: invalid timestamp header", "header", tsHeader)
- http.Error(w, "bad request", http.StatusBadRequest)
- return
- }
- delta := time.Now().Unix() - tsUnix
- if delta < 0 {
- delta = -delta
+ // Step 0: Handle Lark URL verification challenge sent during callback URL setup.
+ // Lark sends {"type":"url_verification","challenge":"..."} with no signature headers.
+ var maybeChallenge struct {
+ Type string `json:"type"`
+ Challenge string `json:"challenge"`
}
- if delta > slackReplayWindowSeconds {
- h.log.Warn("slack webhook: request timestamp outside replay window",
- "timestamp", tsUnix,
- "delta_seconds", delta,
- )
- http.Error(w, "bad request", http.StatusBadRequest)
+ if json.Unmarshal(rawBody, &maybeChallenge) == nil && maybeChallenge.Type == "url_verification" {
+ h.log.Info("lark webhook: responding to URL verification challenge")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"challenge":"` + maybeChallenge.Challenge + `"}`))
return
}
- // Step 3: Verify HMAC-SHA256 signature (req 3.2, 3.3).
- baseString := "v0:" + tsHeader + ":" + string(rawBody)
- mac := hmac.New(sha256.New, []byte(h.signingSecret))
- mac.Write([]byte(baseString))
- expectedSig := "v0=" + hex.EncodeToString(mac.Sum(nil))
-
- providedSig := r.Header.Get("X-Slack-Signature")
- if !hmac.Equal([]byte(expectedSig), []byte(providedSig)) {
- h.log.Warn("slack webhook: signature mismatch")
+ // Step 2–4: Verify request authenticity, then parse payload.
+ // Three modes are supported:
+ // - Real Lark v2 (schema:"2.0"): token is in header.token; fields are nested under event.
+ // - Real Lark v1 (flat): token is in the root "token" field.
+ // - mock-lark: HMAC headers (X-Lark-Request-Timestamp / Nonce / Signature).
+ var envelope larkCallbackEnvelope
+ if err := json.Unmarshal(rawBody, &envelope); err != nil {
+ h.log.Error("lark webhook: unmarshal payload failed", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
- // Step 4: URL-decode the `payload` form field from rawBody; unmarshal into struct (req design step 6).
- formValues, err := url.ParseQuery(string(rawBody))
- if err != nil {
- h.log.Error("slack webhook: parse form body failed", "error", err)
- http.Error(w, "bad request", http.StatusBadRequest)
- return
- }
- payloadEncoded := formValues.Get("payload")
- if payloadEncoded == "" {
- h.log.Warn("slack webhook: missing payload field")
- http.Error(w, "bad request", http.StatusBadRequest)
- return
+ // Normalise v1/v2 into flat variables.
+ var token, openID string
+ var action larkCardCallbackAction
+ if envelope.Schema == "2.0" {
+ token = envelope.Header.Token
+ openID = envelope.Event.Operator.OpenID
+ action = envelope.Event.Action
+ } else {
+ token = envelope.Token
+ openID = envelope.OpenID
+ action = envelope.Action
}
- payloadJSON, err := url.QueryUnescape(payloadEncoded)
- if err != nil {
- h.log.Error("slack webhook: unescape payload failed", "error", err)
- http.Error(w, "bad request", http.StatusBadRequest)
- return
- }
- var payload slackBlockActionsPayload
- if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
- h.log.Error("slack webhook: unmarshal payload failed", "error", err)
- http.Error(w, "bad request", http.StatusBadRequest)
- return
+
+ tsHeader := r.Header.Get("X-Lark-Request-Timestamp")
+ if tsHeader == "" {
+ // Real Lark path: verify using the token embedded in the body.
+ if token != h.verificationToken {
+ h.log.Warn("lark webhook: token mismatch")
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ } else {
+ // mock-lark path: verify using HMAC headers.
+ tsUnix, err := strconv.ParseInt(tsHeader, 10, 64)
+ if err != nil {
+ h.log.Warn("lark webhook: invalid timestamp header", "header", tsHeader)
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ delta := time.Now().Unix() - tsUnix
+ if delta < 0 {
+ delta = -delta
+ }
+ if delta > larkReplayWindowSeconds {
+ h.log.Warn("lark webhook: request timestamp outside replay window",
+ "timestamp", tsUnix,
+ "delta_seconds", delta,
+ )
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ nonce := r.Header.Get("X-Lark-Request-Nonce")
+ expectedSig := computeLarkSignature(h.verificationToken, tsHeader, nonce, rawBody)
+ providedSig := r.Header.Get("X-Lark-Signature")
+ if expectedSig != providedSig {
+ h.log.Warn("lark webhook: signature mismatch")
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
}
- if len(payload.Actions) == 0 {
- h.log.Warn("slack webhook: no actions in payload")
+
+ // Step 5–6: Route on action value; extract ticketID.
+ ticketID := action.Value.TicketID
+ userID := openID
+ actionName := action.Value.Action
+
+ if ticketID == "" {
+ h.log.Warn("lark webhook: missing ticket_id in action value")
http.Error(w, "bad request", http.StatusBadRequest)
return
}
- // Step 5–6: Route on action_id; extract ticketID from button value (req design steps 7–8).
- action := payload.Actions[0]
- userID := payload.User.ID
- ticketID := action.Value
-
var status string
- switch action.ActionID {
- case "approval_approve":
+ switch actionName {
+ case "approve":
status = "approved"
- case "approval_deny":
+ case "deny":
status = "denied"
default:
- h.log.Warn("slack webhook: unknown action_id",
- "action_id", action.ActionID,
+ h.log.Warn("lark webhook: unknown action",
+ "action", actionName,
"ticketID", ticketID,
)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
- // Step 7: Persist the decision to Postgres BEFORE publishing the signal (req 4.3).
- // On failure: return 500 (do NOT publish — decision not persisted, Slack will retry).
+ // Step 7: Persist the decision to Postgres BEFORE publishing the signal.
+ // On failure: return 500 (do NOT publish — decision not persisted, Lark will retry).
if err := h.tickets.UpdateStatus(ctx, ticketID, status, userID); err != nil {
- h.log.Error("slack webhook: UpdateStatus failed",
+ h.log.Error("lark webhook: UpdateStatus failed",
"ticketID", ticketID,
"status", status,
"error", err,
@@ -226,17 +270,21 @@ func (h *SlackWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
- // Step 8: Publish resume signal to the per-ticket Redis channel (req 4.1, 4.2).
- // On failure: log warning but return 200 — ticket is persisted; waiter will timeout (req 5.3).
+ // Step 8: Publish resume signal to the per-ticket Redis channel.
+ // On failure: log warning but return 200 — ticket is persisted; waiter will timeout.
channel := "approvals:" + ticketID
if err := h.redis.Publish(ctx, channel, status); err != nil {
- h.log.Warn("slack webhook: Redis Publish failed; ticket persisted, waiter will timeout",
+ h.log.Warn("lark webhook: Redis Publish failed; ticket persisted, waiter will timeout",
"ticketID", ticketID,
"channel", channel,
"error", err,
)
}
- // Step 9: Return HTTP 200 to dismiss the Slack button interaction (req 4.4).
+ h.log.Info("lark webhook: decision recorded", "ticketID", ticketID, "status", status, "userID", userID)
+
+ // Step 9: Return HTTP 200 with an empty JSON body.
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
}
diff --git a/cmd/gateway/slack_webhook_test.go b/cmd/gateway/slack_webhook_test.go
index 70605f9..ae7b56a 100644
--- a/cmd/gateway/slack_webhook_test.go
+++ b/cmd/gateway/slack_webhook_test.go
@@ -2,14 +2,10 @@ package main
import (
"context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
- "net/url"
"strings"
"testing"
"time"
@@ -59,72 +55,72 @@ func (m *mockRedisPublisher) Publish(_ context.Context, channel string, message
// --- Test Helpers ---
-// signSlackRequest computes the correct Slack HMAC-SHA256 signature for a test request.
-func signSlackRequest(t *testing.T, signingSecret string, timestamp string, body []byte) string {
+// signLarkRequest computes the correct Lark signature for a test request.
+func signLarkRequest(t *testing.T, verificationToken, timestamp, nonce string, body []byte) string {
t.Helper()
- base := "v0:" + timestamp + ":" + string(body)
- mac := hmac.New(sha256.New, []byte(signingSecret))
- mac.Write([]byte(base))
- return "v0=" + hex.EncodeToString(mac.Sum(nil))
+ return computeLarkSignature(verificationToken, timestamp, nonce, body)
}
-// buildSlackActionBody constructs a URL-encoded Slack action request body.
-func buildSlackActionBody(t *testing.T, actionID, ticketID, userID string) []byte {
+// buildLarkActionBody constructs a Lark card callback JSON body.
+func buildLarkActionBody(t *testing.T, action, ticketID, openID string) []byte {
t.Helper()
- payload := slackBlockActionsPayload{
- Type: "block_actions",
- User: slackUser{ID: userID},
- Actions: []slackAction{
- {ActionID: actionID, Value: ticketID},
+ payload := larkCallbackEnvelope{
+ OpenID: openID,
+ Action: larkCardCallbackAction{
+ Tag: "button",
+ Value: larkActionValue{
+ TicketID: ticketID,
+ Action: action,
+ },
},
}
- jsonBytes, err := json.Marshal(payload)
+ body, err := json.Marshal(payload)
if err != nil {
- t.Fatalf("buildSlackActionBody: marshal: %v", err)
+ t.Fatalf("buildLarkActionBody: marshal: %v", err)
}
- // Slack sends the payload as a URL-encoded form field
- encoded := url.QueryEscape(string(jsonBytes))
- return []byte("payload=" + encoded)
+ return body
}
-// newSlackWebhookTestHandler creates a SlackWebhookHandler with mock dependencies.
-func newSlackWebhookTestHandler(signingSecret string, tickets ticketStatusUpdater, redis redisPublisher) *SlackWebhookHandler {
- return newSlackWebhookHandlerWithDeps(signingSecret, tickets, redis, nil)
+// newLarkWebhookTestHandler creates a LarkWebhookHandler with mock dependencies.
+func newLarkWebhookTestHandler(verificationToken string, tickets ticketStatusUpdater, redis redisPublisher) *LarkWebhookHandler {
+ return newLarkWebhookHandlerWithDeps(verificationToken, tickets, redis, nil)
}
-// buildSignedRequest creates an httptest request with correct Slack signature headers.
-func buildSignedRequest(t *testing.T, signingSecret string, body []byte) *http.Request {
+// buildSignedLarkRequest creates an httptest request with correct Lark signature headers.
+func buildSignedLarkRequest(t *testing.T, verificationToken string, body []byte) *http.Request {
t.Helper()
timestamp := fmt.Sprintf("%d", time.Now().Unix())
- sig := signSlackRequest(t, signingSecret, timestamp, body)
-
- req := httptest.NewRequest(http.MethodPost, "/slack/actions", strings.NewReader(string(body)))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("X-Slack-Request-Timestamp", timestamp)
- req.Header.Set("X-Slack-Signature", sig)
+ nonce := "test-nonce-abc"
+ sig := signLarkRequest(t, verificationToken, timestamp, nonce, body)
+
+ req := httptest.NewRequest(http.MethodPost, "/lark/actions", strings.NewReader(string(body)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Lark-Request-Timestamp", timestamp)
+ req.Header.Set("X-Lark-Request-Nonce", nonce)
+ req.Header.Set("X-Lark-Signature", sig)
return req
}
// --- Tests ---
-// TestSlackWebhookApproveAction verifies that a valid approve action results in:
-// - UpdateStatus called with "approved" and the correct ticketID and userID
+// TestLarkWebhookApproveAction verifies that a valid approve action results in:
+// - UpdateStatus called with "approved" and the correct ticketID and openID
// - Redis Publish called on the correct channel
// - HTTP 200 returned
-func TestSlackWebhookApproveAction(t *testing.T) {
+func TestLarkWebhookApproveAction(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-approve-001"
- const userID = "U12345"
+ const openID = "ou_U12345"
var calls []string
tickets := &mockTicketStore{calls: &calls}
redis := &mockRedisPublisher{calls: &calls}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "approval_approve", ticketID, userID)
- req := buildSignedRequest(t, signingSecret, body)
+ body := buildLarkActionBody(t, "approve", ticketID, openID)
+ req := buildSignedLarkRequest(t, verificationToken, body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -141,8 +137,8 @@ func TestSlackWebhookApproveAction(t *testing.T) {
if tickets.updateStatusStatus != "approved" {
t.Errorf("UpdateStatus status = %q, want %q", tickets.updateStatusStatus, "approved")
}
- if tickets.updateStatusBy != userID {
- t.Errorf("UpdateStatus decidedBy = %q, want %q", tickets.updateStatusBy, userID)
+ if tickets.updateStatusBy != openID {
+ t.Errorf("UpdateStatus decidedBy = %q, want %q", tickets.updateStatusBy, openID)
}
if !redis.publishCalled {
t.Fatal("Redis Publish not called, want called after successful UpdateStatus")
@@ -159,24 +155,24 @@ func TestSlackWebhookApproveAction(t *testing.T) {
}
}
-// TestSlackWebhookDenyAction verifies that a valid deny action results in:
+// TestLarkWebhookDenyAction verifies that a valid deny action results in:
// - UpdateStatus called with "denied"
// - Redis Publish called with "denied"
// - HTTP 200 returned
-func TestSlackWebhookDenyAction(t *testing.T) {
+func TestLarkWebhookDenyAction(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-deny-002"
- const userID = "U67890"
+ const openID = "ou_U67890"
var calls []string
tickets := &mockTicketStore{calls: &calls}
redis := &mockRedisPublisher{calls: &calls}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "approval_deny", ticketID, userID)
- req := buildSignedRequest(t, signingSecret, body)
+ body := buildLarkActionBody(t, "deny", ticketID, openID)
+ req := buildSignedLarkRequest(t, verificationToken, body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -187,22 +183,12 @@ func TestSlackWebhookDenyAction(t *testing.T) {
if !tickets.updateStatusCalled {
t.Fatal("UpdateStatus not called, want called with 'denied'")
}
- if tickets.updateStatusID != ticketID {
- t.Errorf("UpdateStatus ticketID = %q, want %q", tickets.updateStatusID, ticketID)
- }
if tickets.updateStatusStatus != "denied" {
t.Errorf("UpdateStatus status = %q, want %q", tickets.updateStatusStatus, "denied")
}
- if tickets.updateStatusBy != userID {
- t.Errorf("UpdateStatus decidedBy = %q, want %q", tickets.updateStatusBy, userID)
- }
if !redis.publishCalled {
t.Fatal("Redis Publish not called on deny action")
}
- wantChannel := "approvals:" + ticketID
- if redis.publishChannel != wantChannel {
- t.Errorf("Redis Publish channel = %q, want %q", redis.publishChannel, wantChannel)
- }
if redis.publishMessage != "denied" {
t.Errorf("Redis Publish message = %q, want %q", redis.publishMessage, "denied")
}
@@ -211,31 +197,32 @@ func TestSlackWebhookDenyAction(t *testing.T) {
}
}
-// TestSlackWebhookBadHMACReturns400 verifies that a request with incorrect HMAC
+// TestLarkWebhookBadSignatureReturns400 verifies that a request with incorrect signature
// returns HTTP 400 and does not call UpdateStatus or Redis Publish.
-func TestSlackWebhookBadHMACReturns400(t *testing.T) {
+func TestLarkWebhookBadSignatureReturns400(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-bad-sig-003"
tickets := &mockTicketStore{}
redis := &mockRedisPublisher{}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "approval_approve", ticketID, "U99999")
+ body := buildLarkActionBody(t, "approve", ticketID, "ou_U99999")
timestamp := fmt.Sprintf("%d", time.Now().Unix())
- req := httptest.NewRequest(http.MethodPost, "/slack/actions", strings.NewReader(string(body)))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("X-Slack-Request-Timestamp", timestamp)
- req.Header.Set("X-Slack-Signature", "v0=badhmacsignaturevalue00000000000000000000000000000000000000000000")
+ req := httptest.NewRequest(http.MethodPost, "/lark/actions", strings.NewReader(string(body)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Lark-Request-Timestamp", timestamp)
+ req.Header.Set("X-Lark-Request-Nonce", "some-nonce")
+ req.Header.Set("X-Lark-Signature", "badsignature0000000000000000000000000000000000000000000000000000")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
- t.Errorf("ServeHTTP() status = %d, want %d (bad HMAC)", rec.Code, http.StatusBadRequest)
+ t.Errorf("ServeHTTP() status = %d, want %d (bad signature)", rec.Code, http.StatusBadRequest)
}
if tickets.updateStatusCalled {
t.Error("UpdateStatus called with bad signature, want no DB call")
@@ -245,28 +232,28 @@ func TestSlackWebhookBadHMACReturns400(t *testing.T) {
}
}
-// TestSlackWebhookReplayAttackReturns400 verifies that a request with a timestamp
+// TestLarkWebhookReplayAttackReturns400 verifies that a request with a timestamp
// older than 5 minutes is rejected with HTTP 400.
-func TestSlackWebhookReplayAttackReturns400(t *testing.T) {
+func TestLarkWebhookReplayAttackReturns400(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-replay-004"
tickets := &mockTicketStore{}
redis := &mockRedisPublisher{}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "approval_approve", ticketID, "U11111")
-
- // Use a timestamp that is 6 minutes in the past (outside 5-minute window)
+ body := buildLarkActionBody(t, "approve", ticketID, "ou_U11111")
oldTimestamp := fmt.Sprintf("%d", time.Now().Add(-6*time.Minute).Unix())
- sig := signSlackRequest(t, signingSecret, oldTimestamp, body)
-
- req := httptest.NewRequest(http.MethodPost, "/slack/actions", strings.NewReader(string(body)))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("X-Slack-Request-Timestamp", oldTimestamp)
- req.Header.Set("X-Slack-Signature", sig)
+ nonce := "test-nonce"
+ sig := signLarkRequest(t, verificationToken, oldTimestamp, nonce, body)
+
+ req := httptest.NewRequest(http.MethodPost, "/lark/actions", strings.NewReader(string(body)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Lark-Request-Timestamp", oldTimestamp)
+ req.Header.Set("X-Lark-Request-Nonce", nonce)
+ req.Header.Set("X-Lark-Signature", sig)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -282,20 +269,20 @@ func TestSlackWebhookReplayAttackReturns400(t *testing.T) {
}
}
-// TestSlackWebhookUpdateStatusFailureReturns500 verifies that when UpdateStatus
+// TestLarkWebhookUpdateStatusFailureReturns500 verifies that when UpdateStatus
// returns an error, the handler returns HTTP 500 and does NOT publish to Redis.
-func TestSlackWebhookUpdateStatusFailureReturns500(t *testing.T) {
+func TestLarkWebhookUpdateStatusFailureReturns500(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-db-fail-005"
tickets := &mockTicketStore{updateStatusErr: fmt.Errorf("db connection error")}
redis := &mockRedisPublisher{}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "approval_approve", ticketID, "U22222")
- req := buildSignedRequest(t, signingSecret, body)
+ body := buildLarkActionBody(t, "approve", ticketID, "ou_U22222")
+ req := buildSignedLarkRequest(t, verificationToken, body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -304,53 +291,53 @@ func TestSlackWebhookUpdateStatusFailureReturns500(t *testing.T) {
t.Errorf("ServeHTTP() status = %d, want %d (UpdateStatus failure)", rec.Code, http.StatusInternalServerError)
}
if redis.publishCalled {
- t.Error("Redis Publish called after UpdateStatus failure, want no publish (decision not persisted)")
+ t.Error("Redis Publish called after UpdateStatus failure, want no publish")
}
}
-// TestSlackWebhookUnknownActionIDReturns400 verifies that an unknown action_id
+// TestLarkWebhookUnknownActionReturns400 verifies that an unknown action value
// results in HTTP 400 with no DB or Redis calls.
-func TestSlackWebhookUnknownActionIDReturns400(t *testing.T) {
+func TestLarkWebhookUnknownActionReturns400(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-unknown-006"
tickets := &mockTicketStore{}
redis := &mockRedisPublisher{}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "some_unknown_action", ticketID, "U33333")
- req := buildSignedRequest(t, signingSecret, body)
+ body := buildLarkActionBody(t, "some_unknown_action", ticketID, "ou_U33333")
+ req := buildSignedLarkRequest(t, verificationToken, body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
- t.Errorf("ServeHTTP() status = %d, want %d (unknown action_id)", rec.Code, http.StatusBadRequest)
+ t.Errorf("ServeHTTP() status = %d, want %d (unknown action)", rec.Code, http.StatusBadRequest)
}
if tickets.updateStatusCalled {
- t.Error("UpdateStatus called for unknown action_id, want no DB call")
+ t.Error("UpdateStatus called for unknown action, want no DB call")
}
if redis.publishCalled {
- t.Error("Redis Publish called for unknown action_id, want no Redis call")
+ t.Error("Redis Publish called for unknown action, want no Redis call")
}
}
-// TestSlackWebhookRedisPublishFailureReturns200 verifies that when Redis Publish
+// TestLarkWebhookRedisPublishFailureReturns200 verifies that when Redis Publish
// fails, the handler still returns HTTP 200 (ticket is persisted; waiter will timeout).
-func TestSlackWebhookRedisPublishFailureReturns200(t *testing.T) {
+func TestLarkWebhookRedisPublishFailureReturns200(t *testing.T) {
t.Parallel()
- const signingSecret = "test-signing-secret"
+ const verificationToken = "test-verification-token"
const ticketID = "ticket-redis-fail-007"
tickets := &mockTicketStore{}
redis := &mockRedisPublisher{publishErr: fmt.Errorf("redis publish error")}
- handler := newSlackWebhookTestHandler(signingSecret, tickets, redis)
+ handler := newLarkWebhookTestHandler(verificationToken, tickets, redis)
- body := buildSlackActionBody(t, "approval_approve", ticketID, "U44444")
- req := buildSignedRequest(t, signingSecret, body)
+ body := buildLarkActionBody(t, "approve", ticketID, "ou_U44444")
+ req := buildSignedLarkRequest(t, verificationToken, body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -363,18 +350,15 @@ func TestSlackWebhookRedisPublishFailureReturns200(t *testing.T) {
}
}
-// TestNewSlackWebhookHandlerConstructor verifies the constructor sets fields correctly.
-func TestNewSlackWebhookHandlerConstructor(t *testing.T) {
+// TestNewLarkWebhookHandlerConstructor verifies the constructor sets the verification token.
+func TestNewLarkWebhookHandlerConstructor(t *testing.T) {
t.Parallel()
- // This test uses the production constructor with *TicketStore and *redis.Client
- // which require real dependencies. We just test that it does not panic with nil logger.
- // The actual behavior is tested by the mock-based tests above.
- h := NewSlackWebhookHandler("secret", nil, nil, nil)
+ h := NewLarkWebhookHandler("my-token", nil, nil, nil)
if h == nil {
- t.Fatal("NewSlackWebhookHandler returned nil")
+ t.Fatal("NewLarkWebhookHandler returned nil")
}
- if h.signingSecret != "secret" {
- t.Errorf("signingSecret = %q, want %q", h.signingSecret, "secret")
+ if h.verificationToken != "my-token" {
+ t.Errorf("verificationToken = %q, want %q", h.verificationToken, "my-token")
}
}
diff --git a/cmd/gateway/ticket.go b/cmd/gateway/ticket.go
index fd1ab09..5ceffc8 100644
--- a/cmd/gateway/ticket.go
+++ b/cmd/gateway/ticket.go
@@ -33,7 +33,7 @@ func NewTicketStore(pool *pgxpool.Pool) *TicketStore {
}
// UpdateStatus transitions a ticket from pending to a terminal status.
-// decidedBy is the Slack user ID for approve/deny; empty string for system-triggered (expired).
+// decidedBy is the Lark open_id for approve/deny; empty string for system-triggered (expired).
// Implementation is idempotent: only updates if current status = 'pending'.
func (s *TicketStore) UpdateStatus(ctx context.Context, id, status, decidedBy string) error {
_, err := s.pool.Exec(ctx, ticketUpdateStatusSQL, id, status, decidedBy)
diff --git a/core/policy/evaluator.go b/core/policy/evaluator.go
index 14cb1ee..fdf47c6 100644
--- a/core/policy/evaluator.go
+++ b/core/policy/evaluator.go
@@ -1,6 +1,7 @@
package policy
import (
+ "encoding/json"
"fmt"
"os"
@@ -29,16 +30,29 @@ func LoadPolicy(path string) (*AgentPolicy, error) {
return &policy, nil
}
-func Evaluate(policy *AgentPolicy, toolName string) PolicyDecision {
+func Evaluate(policy *AgentPolicy, toolName string, args json.RawMessage) PolicyDecision {
+ var argsMap map[string]any
+ if len(args) > 0 {
+ _ = json.Unmarshal(args, &argsMap)
+ }
for _, rule := range policy.Rules {
- if rule.Tool == toolName {
+ if rule.Tool == toolName && matchWhen(rule.When, argsMap) {
return PolicyDecision{Action: rule.Action, RedactFields: rule.RedactFields}
}
}
-
return PolicyDecision{Action: policy.DefaultAction}
}
+func matchWhen(when map[string]any, args map[string]any) bool {
+ for k, wantVal := range when {
+ gotVal, ok := args[k]
+ if !ok || gotVal != wantVal {
+ return false
+ }
+ }
+ return true
+}
+
func validatePolicy(policy *AgentPolicy) error {
if !isRuleAction(policy.DefaultAction) || policy.DefaultAction == ActionApprovalRequired || policy.DefaultAction == ActionRedact {
return fmt.Errorf("defaultAction %q must be allow or deny", policy.DefaultAction)
diff --git a/core/policy/evaluator_test.go b/core/policy/evaluator_test.go
index 8447230..e8a1862 100644
--- a/core/policy/evaluator_test.go
+++ b/core/policy/evaluator_test.go
@@ -269,7 +269,7 @@ func TestEvaluateScenarios(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := Evaluate(policy, tt.toolName)
+ got := Evaluate(policy, tt.toolName, nil)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("Evaluate(%q) = %#v, want %#v", tt.toolName, got, tt.want)
}
@@ -277,7 +277,7 @@ func TestEvaluateScenarios(t *testing.T) {
}
t.Run("empty rules list returns default action", func(t *testing.T) {
- got := Evaluate(&AgentPolicy{DefaultAction: ActionAllow}, "tool_not_listed")
+ got := Evaluate(&AgentPolicy{DefaultAction: ActionAllow}, "tool_not_listed", nil)
want := PolicyDecision{Action: ActionAllow}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Evaluate(empty rules) = %#v, want %#v", got, want)
@@ -285,6 +285,49 @@ func TestEvaluateScenarios(t *testing.T) {
})
}
+func TestEvaluateWhenCondition(t *testing.T) {
+ policy := &AgentPolicy{
+ Rules: []PolicyRule{
+ {Tool: "create_refund", Action: ActionAllow, When: map[string]any{"dry_run": true}},
+ {Tool: "create_refund", Action: ActionApprovalRequired},
+ },
+ Budgets: Budgets{MaxToolCallsPerTurn: 5},
+ DefaultAction: ActionDeny,
+ }
+
+ t.Run("dry_run true matches allow rule", func(t *testing.T) {
+ got := Evaluate(policy, "create_refund", []byte(`{"dry_run":true}`))
+ want := PolicyDecision{Action: ActionAllow}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Evaluate(dry_run=true) = %#v, want %#v", got, want)
+ }
+ })
+
+ t.Run("dry_run false falls through to approvalRequired", func(t *testing.T) {
+ got := Evaluate(policy, "create_refund", []byte(`{"dry_run":false}`))
+ want := PolicyDecision{Action: ActionApprovalRequired}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Evaluate(dry_run=false) = %#v, want %#v", got, want)
+ }
+ })
+
+ t.Run("no dry_run arg falls through to approvalRequired", func(t *testing.T) {
+ got := Evaluate(policy, "create_refund", []byte(`{"amount":100}`))
+ want := PolicyDecision{Action: ActionApprovalRequired}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Evaluate(no dry_run) = %#v, want %#v", got, want)
+ }
+ })
+
+ t.Run("nil args falls through to approvalRequired", func(t *testing.T) {
+ got := Evaluate(policy, "create_refund", nil)
+ want := PolicyDecision{Action: ActionApprovalRequired}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Evaluate(nil args) = %#v, want %#v", got, want)
+ }
+ })
+}
+
func writePolicyFile(t *testing.T, contents string) string {
t.Helper()
diff --git a/core/policy/policy.go b/core/policy/policy.go
index 1a14f1a..010d75f 100644
--- a/core/policy/policy.go
+++ b/core/policy/policy.go
@@ -10,9 +10,10 @@ const (
)
type PolicyRule struct {
- Tool string `yaml:"tool"`
- Action Action `yaml:"action"`
- RedactFields []string `yaml:"redactFields,omitempty"`
+ Tool string `yaml:"tool"`
+ Action Action `yaml:"action"`
+ RedactFields []string `yaml:"redactFields,omitempty"`
+ When map[string]any `yaml:"when,omitempty"`
}
type Budgets struct {
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index c35a342..5de5548 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -27,12 +27,14 @@ services:
POLICY_FILE: /app/policy.yaml
POSTGRES_DSN: postgres://gateway:gateway@postgres:5432/gateway?sslmode=disable
REDIS_DSN: redis://redis:6379/0
- SLACK_BOT_TOKEN: "xoxb-demo-token"
- SLACK_SIGNING_SECRET: "demo-signing-secret"
- SLACK_CHANNEL: "C-DEMO-APPROVALS"
- SLACK_API_BASE_URL: http://mock-slack:8090/api
+ LARK_APP_ID: "app-demo"
+ LARK_APP_SECRET: "demo-secret"
+ LARK_CHAT_ID: "oc_demo-chat"
+ LARK_VERIFICATION_TOKEN: "demo-verification-token"
+ LARK_API_BASE_URL: http://mock-lark:8090/open-apis
SESSION_LOCK_TTL: "3s"
LOCK_ACQUIRE_TIMEOUT: "5s"
+ APPROVAL_TIMEOUT: "25s"
UPSTREAM_MCP_URL: http://localstripe-mcp:8421/mcp
healthcheck:
test:
@@ -45,8 +47,6 @@ services:
retries: 12
start_period: 5s
restart: on-failure
- ports:
- - "18080:8080"
networks:
- eval-gate
@@ -151,24 +151,31 @@ services:
networks:
- eval-gate
- mock-slack:
+ mock-lark:
build:
context: ..
- dockerfile: examples/mock-slack/Dockerfile
+ dockerfile_inline: |
+ FROM golang:1.25-alpine AS builder
+ WORKDIR /build
+ COPY go.mod go.sum ./
+ RUN go mod download
+ COPY . .
+ RUN CGO_ENABLED=0 GOOS=linux go build -o /mock-lark ./examples/mock-lark/
+
+ FROM alpine:latest
+ COPY --from=builder /mock-lark /mock-lark
+ EXPOSE 8090
+ ENTRYPOINT ["/mock-lark"]
depends_on:
gateway:
condition: service_healthy
environment:
GATEWAY_URL: http://gateway:8080
- SLACK_SIGNING_SECRET: demo-signing-secret
+ LARK_VERIFICATION_TOKEN: "demo-verification-token"
expose:
- "8090"
healthcheck:
- test:
- [
- "CMD-SHELL",
- "wget -q -O /dev/null --header='Content-Type: application/json' --post-data='{}' http://127.0.0.1:8090/api/chat.postMessage",
- ]
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/healthz"]
interval: 5s
timeout: 5s
retries: 12
@@ -267,6 +274,9 @@ services:
AI_AGENT_URL: http://eval-trigger:8086
AI_SUITE_PATH: /app/evalsuite/ai-agent.yaml
EVAL_SERVE_PORT: "8099"
+ GATEWAY_MCP_URL: http://gateway:8080/mcp
+ STACK_HEALTH_MCP_ADDR: localstripe-mcp:8421
+ STACK_HEALTH_SLACK_URL: http://mock-lark:8090/healthz
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8099/healthz"]
interval: 5s
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
index 3653f54..78d45cb 100644
--- a/docker-compose.override.yml
+++ b/docker-compose.override.yml
@@ -7,8 +7,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
- mock-slack:
- condition: service_started
environment:
UPSTREAM_MCP_URL: http://localstripe-mcp:8421/mcp
healthcheck:
diff --git a/docker-compose.yml b/docker-compose.yml
index af2d0e1..101b6ef 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ services:
gateway:
image: debian:bookworm-slim
working_dir: /workspace
- command: ["/workspace/.compose-bin/gateway"]
+ command: ["sh", "-c", "apt-get update -qq && apt-get install -y -qq ca-certificates > /dev/null 2>&1 && /workspace/.compose-bin/gateway"]
depends_on:
fake-upstream:
condition: service_started
@@ -10,24 +10,42 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ mock-lark:
+ condition: service_started
environment:
GATEWAY_PORT: "8080"
POLICY_FILE: policy.yaml
POSTGRES_DSN: postgres://gateway:gateway@postgres:5432/gateway?sslmode=disable
REDIS_DSN: redis://redis:6379/0
- SLACK_BOT_TOKEN: "xoxb-demo-token"
- SLACK_SIGNING_SECRET: "demo-signing-secret"
- SLACK_CHANNEL: "C-DEMO-APPROVALS"
- SLACK_API_BASE_URL: "http://mock-slack:8090/api"
+ LARK_APP_ID: ${LARK_APP_ID:-cli_demo_app_id}
+ LARK_APP_SECRET: ${LARK_APP_SECRET:-demo_app_secret}
+ LARK_CHAT_ID: ${LARK_CHAT_ID:-oc_demo_approvals}
+ LARK_VERIFICATION_TOKEN: ${LARK_VERIFICATION_TOKEN:-demo-verification-token}
+ LARK_API_BASE_URL: ${LARK_API_BASE_URL}
SESSION_LOCK_TTL: "3s"
LOCK_ACQUIRE_TIMEOUT: "5s"
- APPROVAL_LOCK_TTL: "15s"
+ APPROVAL_TIMEOUT: "180s"
UPSTREAM_MCP_URL: http://fake-upstream:8081/mcp
ports:
- "18080:8080"
volumes:
- .:/workspace
+ mock-lark:
+ build:
+ context: .
+ dockerfile_inline: |
+ FROM golang:1.25-alpine
+ WORKDIR /app
+ COPY . .
+ RUN go build -o mock-lark ./examples/mock-lark
+ ENTRYPOINT ["./mock-lark"]
+ environment:
+ GATEWAY_URL: http://gateway:8080
+ LARK_VERIFICATION_TOKEN: "demo-verification-token"
+ ports:
+ - "18090:8090"
+
fake-upstream:
image: python:3.12-alpine
working_dir: /workspace
@@ -62,6 +80,8 @@ services:
timeout: 5s
retries: 12
start_period: 5s
+ ports:
+ - "16379:6379"
localstripe:
build:
@@ -136,22 +156,6 @@ services:
retries: 15
start_period: 10s
- mock-slack:
- build:
- context: .
- dockerfile: examples/mock-slack/Dockerfile
- environment:
- GATEWAY_URL: http://gateway:8080
- SLACK_SIGNING_SECRET: "demo-signing-secret"
- ports:
- - "18090:8090"
- healthcheck:
- test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/healthz 2>/dev/null || exit 0"]
- interval: 5s
- timeout: 5s
- retries: 6
- start_period: 5s
-
demo-webapp:
build:
context: ./localstripe_demo
diff --git a/evalsuite/ai-agent.yaml b/evalsuite/ai-agent.yaml
index a1c1400..d4f135a 100644
--- a/evalsuite/ai-agent.yaml
+++ b/evalsuite/ai-agent.yaml
@@ -10,3 +10,4 @@ cases:
mustInclude:
- list_recent_charges
- create_refund
+ policyOutcome: approvalRequired
diff --git a/evalsuite/default.yaml b/evalsuite/default.yaml
index 6623679..096de53 100644
--- a/evalsuite/default.yaml
+++ b/evalsuite/default.yaml
@@ -20,7 +20,7 @@ cases:
- name: pii-redact
input: pii-message
mustInclude:
- - send_slack_message
+ - send_lark_message
policyOutcome: allow
mustNotContainInArgs:
- "123-45-6789"
diff --git a/evalsuite/resilience-s3.yaml b/evalsuite/resilience-s3.yaml
index 72655fc..900685b 100644
--- a/evalsuite/resilience-s3.yaml
+++ b/evalsuite/resilience-s3.yaml
@@ -1,5 +1,5 @@
cases:
- - name: approval-timeout-slack-down
+ - name: approval-timeout-lark-down
input: >
List recent charges for alice@example.com, then issue a full refund on
the first non-refunded charge with reason requested_by_customer.
diff --git a/evalsuite/resilience.yaml b/evalsuite/resilience.yaml
index efa5d21..504d2c6 100644
--- a/evalsuite/resilience.yaml
+++ b/evalsuite/resilience.yaml
@@ -5,7 +5,7 @@ cases:
- list_recent_charges
policyOutcome: upstream_error
- - name: approval-timeout-slack-down
+ - name: approval-timeout-lark-down
input: >
List my recent charges, then issue a full refund on the first
non-refunded charge with reason requested_by_customer.
diff --git a/examples/fake-mcp-servers/stripe/main.go b/examples/fake-mcp-servers/stripe/main.go
index ab56ced..17a1a92 100644
--- a/examples/fake-mcp-servers/stripe/main.go
+++ b/examples/fake-mcp-servers/stripe/main.go
@@ -73,7 +73,7 @@ func deleteRecordHandler(_ context.Context, _ *mcp.CallToolRequest, args DeleteR
}
func sendSlackMessageHandler(_ context.Context, _ *mcp.CallToolRequest, args SendSlackMessageParams) (*mcp.CallToolResult, any, error) {
- payload := fmt.Sprintf(`{"ok":true,"tool":"send_slack_message","channel":%q,"message":%q}`, args.Channel, args.Message)
+ payload := fmt.Sprintf(`{"ok":true,"tool":"send_lark_message","channel":%q,"message":%q}`, args.Channel, args.Message)
return cannedJSONResult(payload), nil, nil
}
@@ -125,8 +125,8 @@ func main() {
)
mcp.AddTool(server,
&mcp.Tool{
- Name: "send_slack_message",
- Description: "Send a Slack message (demo contract)",
+ Name: "send_lark_message",
+ Description: "Send a Lark message (demo contract)",
},
sendSlackMessageHandler,
)
diff --git a/examples/fake-mcp-servers/stripe/main_test.go b/examples/fake-mcp-servers/stripe/main_test.go
index 121ee5b..b31ad0d 100644
--- a/examples/fake-mcp-servers/stripe/main_test.go
+++ b/examples/fake-mcp-servers/stripe/main_test.go
@@ -56,7 +56,7 @@ func TestSendSlackMessageHandler(t *testing.T) {
}
payload := extractPayload(t, result)
- if got, want := payload["tool"], "send_slack_message"; got != want {
+ if got, want := payload["tool"], "send_lark_message"; got != want {
t.Fatalf("payload tool = %v, want %q", got, want)
}
if got, want := payload["message"], "***REDACTED***"; got != want {
diff --git a/examples/mock-lark/main.go b/examples/mock-lark/main.go
new file mode 100644
index 0000000..c7d2490
--- /dev/null
+++ b/examples/mock-lark/main.go
@@ -0,0 +1,168 @@
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+)
+
+var (
+ gatewayURL string
+ verificationToken string
+)
+
+func main() {
+ gatewayURL = os.Getenv("GATEWAY_URL")
+ verificationToken = os.Getenv("LARK_VERIFICATION_TOKEN")
+ if gatewayURL == "" || verificationToken == "" {
+ log.Fatal("GATEWAY_URL and LARK_VERIFICATION_TOKEN are required")
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", handleTenantToken)
+ mux.HandleFunc("/open-apis/im/v1/messages", handleSendMessage)
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
+ log.Println("mock-lark listening on :8090")
+ log.Fatal(http.ListenAndServe(":8090", mux))
+}
+
+// handleTenantToken returns a mock tenant access token — no real Lark credentials needed.
+func handleTenantToken(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"mock-tenant-token","expire":7200}`))
+}
+
+// handleSendMessage receives a Lark interactive card message from the gateway,
+// extracts the ticket_id, acknowledges immediately, then asynchronously sends
+// an approve action back to the gateway's /lark/actions endpoint.
+func handleSendMessage(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ log.Printf("mock-lark: read body error: %v", err)
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ ticketID, err := extractTicketID(body)
+ if err != nil {
+ log.Printf("mock-lark: extract ticket_id error: %v", err)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"code":0,"msg":"ok"}`))
+
+ if ticketID != "" {
+ go sendApproveAction(ticketID)
+ }
+}
+
+// extractTicketID parses the Lark card JSON (nested inside the content string field)
+// to find the ticket_id value in the first button's value map.
+func extractTicketID(msgBody []byte) (string, error) {
+ var msg struct {
+ Content string `json:"content"`
+ }
+ if err := json.Unmarshal(msgBody, &msg); err != nil {
+ return "", fmt.Errorf("unmarshal message: %w", err)
+ }
+
+ var card struct {
+ Elements []struct {
+ Tag string `json:"tag"`
+ Actions []struct {
+ Value map[string]string `json:"value"`
+ } `json:"actions"`
+ } `json:"elements"`
+ }
+ if err := json.Unmarshal([]byte(msg.Content), &card); err != nil {
+ return "", fmt.Errorf("unmarshal card: %w", err)
+ }
+
+ for _, elem := range card.Elements {
+ if elem.Tag != "action" {
+ continue
+ }
+ if len(elem.Actions) > 0 {
+ return elem.Actions[0].Value["ticket_id"], nil
+ }
+ }
+ return "", nil
+}
+
+// computeSignature computes the Lark-compatible HMAC signature used by the gateway.
+// Formula: hex(sha256(verificationToken + timestamp + nonce + body))
+func computeSignature(token, timestamp, nonce string, body []byte) string {
+ h := sha256.New()
+ h.Write([]byte(token))
+ h.Write([]byte(timestamp))
+ h.Write([]byte(nonce))
+ h.Write(body)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// sendApproveAction waits 50ms then POSTs a signed Lark card callback payload to
+// the gateway's /lark/actions endpoint with action "approve".
+func sendApproveAction(ticketID string) {
+ time.Sleep(50 * time.Millisecond)
+
+ type actionValue struct {
+ TicketID string `json:"ticket_id"`
+ Action string `json:"action"`
+ }
+ type cardAction struct {
+ Tag string `json:"tag"`
+ Value actionValue `json:"value"`
+ }
+ type callbackPayload struct {
+ OpenID string `json:"open_id"`
+ Action cardAction `json:"action"`
+ }
+
+ payload := callbackPayload{
+ OpenID: "ou_mock-user",
+ Action: cardAction{
+ Tag: "button",
+ Value: actionValue{
+ TicketID: ticketID,
+ Action: "approve",
+ },
+ },
+ }
+ payloadJSON, err := json.Marshal(payload)
+ if err != nil {
+ log.Printf("mock-lark: marshal approve payload error: %v", err)
+ return
+ }
+
+ timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+ nonce := "mock-nonce-" + timestamp
+ sig := computeSignature(verificationToken, timestamp, nonce, payloadJSON)
+
+ req, err := http.NewRequest(http.MethodPost, gatewayURL+"/lark/actions", bytes.NewReader(payloadJSON))
+ if err != nil {
+ log.Printf("mock-lark: build approve request error: %v", err)
+ return
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Lark-Request-Timestamp", timestamp)
+ req.Header.Set("X-Lark-Request-Nonce", nonce)
+ req.Header.Set("X-Lark-Signature", sig)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Printf("mock-lark: approve POST error: %v", err)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+ log.Printf("mock-lark: gateway /lark/actions response: %d", resp.StatusCode)
+}
diff --git a/examples/mock-slack/main.go b/examples/mock-slack/main.go
index ca1f670..8bf3e3f 100644
--- a/examples/mock-slack/main.go
+++ b/examples/mock-slack/main.go
@@ -29,6 +29,7 @@ func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/chat.postMessage", handleChatPostMessage)
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
log.Println("mock-slack listening on :8090")
log.Fatal(http.ListenAndServe(":8090", mux))
}
diff --git a/examples/support-agent/agent.py b/examples/support-agent/agent.py
index ec27874..6276e6a 100644
--- a/examples/support-agent/agent.py
+++ b/examples/support-agent/agent.py
@@ -11,7 +11,7 @@
"lookup-charge": ("list_recent_charges", {"limit": 1}),
"create-refund": ("create_refund", {"charge_or_pi": "ch_fake_001", "reason": "requested_by_customer"}),
"deny-test": ("delete_customer", {"customer_id": "cust_001"}),
- "pii-message": ("send_slack_message", {"channel": "#support", "message": "Customer SSN: 123-45-6789"}),
+ "pii-message": ("send_lark_message", {"channel": "#support", "message": "Customer SSN: 123-45-6789"}),
}
diff --git a/localstripe_demo b/localstripe_demo
index fe7617d..1f9357a 160000
--- a/localstripe_demo
+++ b/localstripe_demo
@@ -1 +1 @@
-Subproject commit fe7617da7f0342532c45cc8088568e6290334c48
+Subproject commit 1f9357a57447e72f44ee6ad62fa37874e6c822d3
diff --git a/policy.yaml b/policy.yaml
index 919806b..4ffa70a 100644
--- a/policy.yaml
+++ b/policy.yaml
@@ -7,9 +7,13 @@ rules:
action: allow
- tool: list_refunds_for_charge
action: allow
+ - tool: create_refund
+ when:
+ dry_run: true
+ action: allow
- tool: create_refund
action: approvalRequired
- - tool: send_slack_message
+ - tool: send_lark_message
action: redact
redactFields:
- message
diff --git a/scripts/fake_upstream.py b/scripts/fake_upstream.py
index 257eb4c..3f5dcc9 100755
--- a/scripts/fake_upstream.py
+++ b/scripts/fake_upstream.py
@@ -41,8 +41,8 @@
},
},
{
- "name": "send_slack_message",
- "description": "Send a Slack message",
+ "name": "send_lark_message",
+ "description": "Send a Lark message",
"inputSchema": {
"type": "object",
"properties": {