Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5917c9a
feat(contentsafety): add extension interface layer with Provider, Ale…
MaxHuang22 Apr 21, 2026
3a5d866
feat(contentsafety): add normalize utility for JSON type conversion
MaxHuang22 Apr 21, 2026
a59b9df
feat(contentsafety): add tree walker and regex scanner
MaxHuang22 Apr 21, 2026
9c41f9b
feat(contentsafety): add config loading with lazy creation, default r…
MaxHuang22 Apr 21, 2026
782484d
feat(contentsafety): add regex provider with config-driven scanning a…
MaxHuang22 Apr 21, 2026
fe202eb
feat(contentsafety): add output core with mode parsing, path normaliz…
MaxHuang22 Apr 21, 2026
3e27161
feat(contentsafety): add ScanForSafety entry point and Envelope alert…
MaxHuang22 Apr 21, 2026
8740f82
feat(contentsafety): integrate scanning into shortcut Out() and OutFo…
MaxHuang22 Apr 21, 2026
45e0048
feat(contentsafety): integrate scanning into API/service output paths…
MaxHuang22 Apr 21, 2026
d3071e5
fix(contentsafety): emit stderr notice when lazy-creating default config
MaxHuang22 Apr 22, 2026
25704f0
style: gofmt factory_default and exitcode
MaxHuang22 Apr 22, 2026
5a1aad9
fix(contentsafety): vfs for config I/O, mutex for lazy-create, sort m…
MaxHuang22 Apr 22, 2026
4dba5ee
fix(contentsafety): isolate scan goroutine errOut to prevent race on …
MaxHuang22 Apr 22, 2026
27c6a8c
fix(contentsafety): deep-normalize typed slices so scanner can walk s…
MaxHuang22 Apr 22, 2026
5307f71
fix(contentsafety): file perms 0600/0700, no result mutation, timeout…
MaxHuang22 Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,13 @@ func apiRun(opts *APIOptions) error {
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
})
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).
Expand Down
15 changes: 8 additions & 7 deletions cmd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,14 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return output.ErrNetwork("API call failed: %s", err)
}
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CheckError: checkErr,
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
CheckError: checkErr,
})
}

Expand Down
28 changes: 28 additions & 0 deletions extension/contentsafety/registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package contentsafety

import "sync"

var (
mu sync.Mutex
provider Provider
)

// Register installs a content-safety Provider. Later registrations
// override earlier ones (last-write-wins).
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
provider = p
}

// GetProvider returns the currently registered Provider.
// Returns nil if no provider has been registered.
func GetProvider() Provider {
mu.Lock()
defer mu.Unlock()
return provider
}
29 changes: 29 additions & 0 deletions extension/contentsafety/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package contentsafety

import (
"context"
"io"
)

// Provider scans parsed response data for content-safety issues.
// Implementations must be safe for concurrent use.
type Provider interface {
Name() string
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
}

// ScanRequest carries the data to scan.
type ScanRequest struct {
Path string // normalized command path (e.g. "im.messages_search")
Data any // parsed response data (generic JSON shape)
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
}

// Alert holds the result of a content-safety scan that detected issues.
type Alert struct {
Provider string `json:"provider"`
MatchedRules []string `json:"matched_rules"`
}
70 changes: 70 additions & 0 deletions extension/contentsafety/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package contentsafety

import (
"context"
"io"
"testing"
)

func TestAlertFields(t *testing.T) {
a := &Alert{
Provider: "regex",
MatchedRules: []string{"rule_a", "rule_b"},
}
if a.Provider != "regex" {
t.Errorf("Provider = %q, want %q", a.Provider, "regex")
}
if len(a.MatchedRules) != 2 {
t.Errorf("MatchedRules length = %d, want 2", len(a.MatchedRules))
}
}

type stubProvider struct{}

func (s *stubProvider) Name() string { return "stub" }
func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
}

func TestProviderInterface(t *testing.T) {
var p Provider = &stubProvider{}
if p.Name() != "stub" {
t.Errorf("Name() = %q, want %q", p.Name(), "stub")
}
alert, err := p.Scan(context.Background(), ScanRequest{Path: "test", Data: nil, ErrOut: io.Discard})
if err != nil {
t.Fatalf("Scan() error = %v", err)
}
if alert.Provider != "stub" {
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
}
}

func TestRegistryLastWriteWins(t *testing.T) {
mu.Lock()
old := provider
provider = nil
mu.Unlock()
defer func() {
mu.Lock()
provider = old
mu.Unlock()
}()

if GetProvider() != nil {
t.Fatal("expected nil provider initially")
}
p1 := &stubProvider{}
Register(p1)
if GetProvider() != p1 {
t.Fatal("expected p1 after first Register")
}
p2 := &stubProvider{}
Register(p2)
if GetProvider() != p2 {
t.Fatal("expected p2 after second Register (last-write-wins)")
}
}
24 changes: 18 additions & 6 deletions internal/client/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@

// ResponseOptions configures how HandleResponse routes a raw API response.
type ResponseOptions struct {
OutputPath string // --output flag; "" = auto-detect
Format output.Format // output format for JSON responses
JqExpr string // if set, apply jq filter instead of Format
Out io.Writer // stdout
ErrOut io.Writer // stderr
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
OutputPath string // --output flag; "" = auto-detect
Format output.Format // output format for JSON responses
JqExpr string // if set, apply jq filter instead of Format
Out io.Writer // stdout
ErrOut io.Writer // stderr
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
CommandPath string // raw cobra CommandPath() for content safety scanning
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
CheckError func(interface{}) error
}
Expand Down Expand Up @@ -60,9 +61,20 @@
if apiErr := check(result); apiErr != nil {
return apiErr
}
// Content safety scanning
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}

Check warning on line 68 in internal/client/response.go

View check run for this annotation

Codecov / codecov/patch

internal/client/response.go#L67-L68

Added lines #L67 - L68 were not covered by tests
if opts.OutputPath != "" {
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}

Check warning on line 72 in internal/client/response.go

View check run for this annotation

Codecov / codecov/patch

internal/client/response.go#L70-L72

Added lines #L70 - L72 were not covered by tests
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}

Check warning on line 77 in internal/client/response.go

View check run for this annotation

Codecov / codecov/patch

internal/client/response.go#L76-L77

Added lines #L76 - L77 were not covered by tests
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
}
Expand Down
1 change: 1 addition & 0 deletions internal/cmdutil/factory_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/util"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
Expand Down
3 changes: 3 additions & 0 deletions internal/envvars/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ const (
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar

// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
)
61 changes: 61 additions & 0 deletions internal/output/emit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package output

import (
"errors"
"fmt"
"io"
"strings"

extcs "github.com/larksuite/cli/extension/contentsafety"
)

// ScanResult holds the output of ScanForSafety.
type ScanResult struct {
Alert *extcs.Alert
Blocked bool
BlockErr error
}

// ScanForSafety runs content-safety scanning on the given data.
// cmdPath is the raw cobra CommandPath().
// When MODE=off, no provider registered, or the command is not allowlisted,
// returns a zero ScanResult.
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut)
if errors.Is(csErr, errBlocked) {
return ScanResult{
Alert: alert,
Blocked: true,
BlockErr: wrapBlockError(alert),
}
}
return ScanResult{Alert: alert}
}

// wrapBlockError creates an ExitError for content-safety block.
func wrapBlockError(alert *extcs.Alert) error {
rules := ""
if alert != nil {
rules = strings.Join(alert.MatchedRules, ", ")
}
return &ExitError{
Code: ExitContentSafety,
Detail: &ErrDetail{
Type: "content_safety_blocked",
Message: fmt.Sprintf("content safety violation detected (rules: %s)", rules),
},
}
}

// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
if alert == nil {
return
}

Check warning on line 58 in internal/output/emit.go

View check run for this annotation

Codecov / codecov/patch

internal/output/emit.go#L57-L58

Added lines #L57 - L58 were not covered by tests
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
alert.Provider, strings.Join(alert.MatchedRules, ", "))
}
Loading
Loading