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": {