Skip to content

Commit 17655ae

Browse files
deane0310claude
andcommitted
feat(contentsafety): add content-safety scanning at CLI output boundary
Add a cross-cutting content-safety layer that scans API responses for prompt injection patterns before they reach AI agents. The feature is opt-in via two environment variables (MODE + ALLOWLIST) and defaults to off with zero impact on existing behavior. Architecture: - extension/contentsafety: pluggable Provider interface and registry - internal/security/contentsafety: built-in regex provider (4 rules) - internal/output: ScanForSafety entry point + EmitLarkResponse - runner.go Out/OutFormat: 5-line block check at top, minimal invasiveness - response.go HandleResponse: routes through EmitLarkResponse Key design decisions: - Single-provider registry (last-write-wins), aligned with extension/transport - 100ms deadline enforced via goroutine+select (works even if provider ignores ctx) - Panic/error/timeout all fail-open with stderr warning - Built-in provider self-registers via init(), activated by blank import in factory_default.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 085ffd8 commit 17655ae

23 files changed

Lines changed: 2129 additions & 29 deletions

cmd/api/api.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,13 @@ func apiRun(opts *APIOptions) error {
238238
return output.MarkRaw(client.WrapDoAPIError(err))
239239
}
240240
err = client.HandleResponse(resp, client.ResponseOptions{
241-
OutputPath: opts.Output,
242-
Format: format,
243-
JqExpr: opts.JqExpr,
244-
Out: out,
245-
ErrOut: f.IOStreams.ErrOut,
246-
FileIO: f.ResolveFileIO(opts.Ctx),
241+
CommandPath: opts.Cmd.CommandPath(),
242+
OutputPath: opts.Output,
243+
Format: format,
244+
JqExpr: opts.JqExpr,
245+
Out: out,
246+
ErrOut: f.IOStreams.ErrOut,
247+
FileIO: f.ResolveFileIO(opts.Ctx),
247248
})
248249
// MarkRaw tells root error handler to skip enrichPermissionError,
249250
// preserving the original API error detail (log_id, troubleshooter, etc.).

cmd/service/service.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,14 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
264264
return output.ErrNetwork("API call failed: %s", err)
265265
}
266266
return client.HandleResponse(resp, client.ResponseOptions{
267-
OutputPath: opts.Output,
268-
Format: format,
269-
JqExpr: opts.JqExpr,
270-
Out: out,
271-
ErrOut: f.IOStreams.ErrOut,
272-
FileIO: f.ResolveFileIO(opts.Ctx),
273-
CheckError: checkErr,
267+
CommandPath: opts.Cmd.CommandPath(),
268+
OutputPath: opts.Output,
269+
Format: format,
270+
JqExpr: opts.JqExpr,
271+
Out: out,
272+
ErrOut: f.IOStreams.ErrOut,
273+
FileIO: f.ResolveFileIO(opts.Ctx),
274+
CheckError: checkErr,
274275
})
275276
}
276277

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contentsafety
5+
6+
import "sync"
7+
8+
var (
9+
mu sync.Mutex
10+
provider Provider
11+
)
12+
13+
// Register installs the content-safety Provider. Later registrations
14+
// override earlier ones (last-write-wins). The built-in regex provider
15+
// registers itself from init() in internal/security/contentsafety when
16+
// that package is blank-imported from main.go.
17+
func Register(p Provider) {
18+
mu.Lock()
19+
defer mu.Unlock()
20+
provider = p
21+
}
22+
23+
// GetProvider returns the currently registered Provider, or nil if none
24+
// is registered. A nil return value means "no scanning" and callers
25+
// should treat it as a silent pass-through.
26+
func GetProvider() Provider {
27+
mu.Lock()
28+
defer mu.Unlock()
29+
return provider
30+
}

extension/contentsafety/types.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contentsafety
5+
6+
import "context"
7+
8+
// Provider scans parsed response data for content-safety issues.
9+
// Implementations must be safe for concurrent use.
10+
type Provider interface {
11+
// Name returns a stable provider identifier. Used in Alert payloads
12+
// and diagnostic output.
13+
Name() string
14+
15+
// Scan inspects req.Data and returns a non-nil Alert when any issue
16+
// is detected, or nil when the data is clean.
17+
//
18+
// Returning a non-nil error signals that the scan itself failed
19+
// (misconfiguration, transient I/O, internal panic). Callers are
20+
// expected to treat scan errors as fail-open.
21+
//
22+
// Scan must respect ctx cancellation and return promptly once
23+
// ctx.Err() becomes non-nil; callers may impose a deadline.
24+
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
25+
}
26+
27+
// ScanRequest carries the data to be scanned plus minimal context.
28+
type ScanRequest struct {
29+
// CmdPath is the normalized command path (e.g. "im.messages_search").
30+
// Providers may use it for per-command logic; most can ignore it.
31+
CmdPath string
32+
33+
// Data is the parsed response payload as it flows through the CLI's
34+
// output layer. It may be a map, slice, string, or a typed struct
35+
// depending on the originating command. Providers must not mutate it.
36+
// Providers that require a uniform shape should perform their own
37+
// normalization internally.
38+
Data any
39+
}
40+
41+
// Alert describes content-safety issues discovered by a Provider.
42+
// An Alert only exists when at least one issue was found; nil means clean.
43+
type Alert struct {
44+
// Provider identifies which provider produced this alert.
45+
Provider string `json:"provider"`
46+
47+
// Matches is the list of issues detected. Guaranteed non-empty
48+
// when the enclosing *Alert is non-nil.
49+
Matches []RuleMatch `json:"matches"`
50+
}
51+
52+
// RuleMatch describes a single rule hit.
53+
type RuleMatch struct {
54+
// Rule is the stable identifier of the matched rule
55+
// (e.g. "instruction_override", "role_injection").
56+
Rule string `json:"rule"`
57+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contentsafety
5+
6+
import (
7+
"context"
8+
"testing"
9+
)
10+
11+
type stubProvider struct{ name string }
12+
13+
func (s *stubProvider) Name() string { return s.name }
14+
func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
15+
return nil, nil
16+
}
17+
18+
func TestGetProvider_NilByDefault(t *testing.T) {
19+
mu.Lock()
20+
saved := provider
21+
provider = nil
22+
mu.Unlock()
23+
t.Cleanup(func() { mu.Lock(); provider = saved; mu.Unlock() })
24+
25+
if got := GetProvider(); got != nil {
26+
t.Fatalf("expected nil, got %v", got)
27+
}
28+
}
29+
30+
func TestRegister_LastWriteWins(t *testing.T) {
31+
mu.Lock()
32+
saved := provider
33+
mu.Unlock()
34+
t.Cleanup(func() { mu.Lock(); provider = saved; mu.Unlock() })
35+
36+
Register(&stubProvider{name: "a"})
37+
Register(&stubProvider{name: "b"})
38+
39+
got := GetProvider()
40+
if got == nil || got.Name() != "b" {
41+
t.Fatalf("expected provider 'b', got %v", got)
42+
}
43+
}

internal/client/response.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ import (
2323

2424
// ResponseOptions configures how HandleResponse routes a raw API response.
2525
type ResponseOptions struct {
26-
OutputPath string // --output flag; "" = auto-detect
27-
Format output.Format // output format for JSON responses
28-
JqExpr string // if set, apply jq filter instead of Format
29-
Out io.Writer // stdout
30-
ErrOut io.Writer // stderr
31-
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
26+
CommandPath string // raw cobra CommandPath(); used by content-safety scanning
27+
OutputPath string // --output flag; "" = auto-detect
28+
Format output.Format // output format for JSON responses
29+
JqExpr string // if set, apply jq filter instead of Format
30+
Out io.Writer // stdout
31+
ErrOut io.Writer // stderr
32+
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
3233
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
3334
CheckError func(interface{}) error
3435
}
@@ -63,11 +64,14 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
6364
if opts.OutputPath != "" {
6465
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
6566
}
66-
if opts.JqExpr != "" {
67-
return output.JqFilter(opts.Out, result, opts.JqExpr)
68-
}
69-
output.FormatValue(opts.Out, result, opts.Format)
70-
return nil
67+
return output.EmitLarkResponse(output.LarkResponseEmitRequest{
68+
CommandPath: opts.CommandPath,
69+
Data: result,
70+
Format: opts.Format.String(),
71+
JqExpr: opts.JqExpr,
72+
Out: opts.Out,
73+
ErrOut: opts.ErrOut,
74+
})
7175
}
7276

7377
// Non-JSON (binary) responses.

internal/cmdutil/factory_default.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/larksuite/cli/internal/credential"
2424
"github.com/larksuite/cli/internal/keychain"
2525
"github.com/larksuite/cli/internal/registry"
26+
_ "github.com/larksuite/cli/internal/security/contentsafety" // register default content-safety provider
2627
"github.com/larksuite/cli/internal/util"
2728
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
2829
)

internal/envvars/envvars.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ const (
1111
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
1212
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
1313
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
14+
15+
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
16+
CliContentSafetyAllowlist = "LARKSUITE_CLI_CONTENT_SAFETY_ALLOWLIST"
1417
)

0 commit comments

Comments
 (0)