Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ func runAPI(cmd *cobra.Command, args []string) error {
if !strings.HasPrefix(pathTemplate, "/") {
pathTemplate = "/" + pathTemplate
}
if strings.ContainsAny(pathTemplate, "?#") {
err := fmt.Errorf("path must not contain query or fragment characters; pass query values via --params")
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}

// Determine HTTP method.
methodFlag, _ := cmd.Flags().GetString("method")
Expand Down Expand Up @@ -173,7 +178,8 @@ func extractPathParamNames(pathTemplate string) map[string]bool {
// Path params are those matching {placeholder} in the path template.
// Input is validated through validate.ValidateParams (keys must match
// [a-zA-Z0-9_]+, values must not contain control characters, see
// MaxParamValueLength). Path params additionally must be numeric IDs.
// MaxParamValueLength). Path params are validated as safe URL path segments;
// the API remains the source of truth for endpoint-specific ID semantics.
func parseAPIParams(pathTemplate, paramsJSON string) (pathParams, queryParams map[string]string, err error) {
pathParams = make(map[string]string)
queryParams = make(map[string]string)
Expand All @@ -190,7 +196,7 @@ func parseAPIParams(pathTemplate, paramsJSON string) (pathParams, queryParams ma
pathParamNames := extractPathParamNames(pathTemplate)
for k, v := range params {
if pathParamNames[k] {
if err := validate.ValidateResourceID(v); err != nil {
if err := validate.ValidatePathSegmentID(v); err != nil {
return nil, nil, fmt.Errorf("path parameter %q: %w", k, err)
}
pathParams[k] = v
Expand Down
184 changes: 176 additions & 8 deletions cmd/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ func apiServer(t *testing.T) *httptest.Server {

// Echo back what was received for verification
response := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.Query(),
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.Query(),
"request_uri": r.RequestURI,
}
data, _ := json.Marshal(response)
_, _ = w.Write(data)
Expand Down Expand Up @@ -136,15 +137,182 @@ func TestAPI_MissingPathParam(t *testing.T) {
}
}

func TestAPI_InvalidPathParam(t *testing.T) {
func TestAPI_PathParamsAllowStringSegments(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

_, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns",
stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}",
"--api-url", server.URL,
"--params", `{"environment_id": "abc"}`)
if err == nil {
t.Fatal("expected error for non-numeric ID")
"--params", `{"environment_id": "217838", "test_user_id": "profile_abc-123"}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var result map[string]any
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if result["path"] != "/v1/environments/217838/test_users/profile_abc-123" {
t.Errorf("expected resolved string path segment, got %v", result["path"])
}
}

func TestAPI_CustomerIDAllowsCIOID(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

params := `{"environment_id": "217838", "customer_id": "eea50d000102"}`
wantPath := "/v1/environments/217838/customers/eea50d000102"

tests := []struct {
name string
args []string
wantKey string
wantValue string
}{
{
name: "get",
args: []string{"api", "/v1/environments/{environment_id}/customers/{customer_id}",
"--api-url", server.URL,
"--params", params},
wantKey: "path",
wantValue: wantPath,
},
{
name: "put",
args: []string{"api", "/v1/environments/{environment_id}/customers/{customer_id}",
"--api-url", server.URL,
"--params", params,
"-X", "PUT",
"--json", `{"customer":{"email":"test@example.com"}}`},
wantKey: "path",
wantValue: wantPath,
},
{
name: "dry-run",
args: []string{"api", "/v1/environments/{environment_id}/customers/{customer_id}",
"--api-url", server.URL,
"--params", params,
"--dry-run"},
wantKey: "url",
wantValue: server.URL + wantPath,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stdout, _, err := executeCommand(tt.args...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var result map[string]any
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if result[tt.wantKey] != tt.wantValue {
t.Errorf("expected %s=%s, got %v", tt.wantKey, tt.wantValue, result[tt.wantKey])
}
})
}
}

func TestAPI_CustomerIDAllowsEmailIdentifier(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/customers/{customer_id}",
"--api-url", server.URL,
"--params", `{"environment_id": "217838", "customer_id": "user@example.com"}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var result map[string]any
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if result["path"] != "/v1/environments/217838/customers/user@example.com" {
t.Errorf("expected email identifier in path, got %v", result["path"])
}
if result["request_uri"] != "/v1/environments/217838/customers/user@example.com" {
t.Errorf("expected email identifier to remain in request path, got request_uri=%v", result["request_uri"])
}
}

func TestAPI_PathParamRejectsReservedPathCharacters(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

tests := []struct {
name string
params string
}{
{
name: "slash",
params: `{"environment_id": "217838", "test_user_id": "eea50d000102/../deliveries"}`,
},
{
name: "pre-encoded",
params: `{"environment_id": "217838", "test_user_id": "user%40example.com"}`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}",
"--api-url", server.URL,
"--params", tt.params)
if err == nil {
t.Fatal("expected error for reserved path character")
}
if !strings.Contains(err.Error(), "reserved path character") {
t.Errorf("expected reserved path character error, got: %v", err)
}
})
}
}

func TestAPI_PathParamRejectsDotSegments(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

for _, value := range []string{".", ".."} {
t.Run(value, func(t *testing.T) {
_, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}",
"--api-url", server.URL,
"--params", `{"environment_id": "217838", "test_user_id": "`+value+`"}`)
if err == nil {
t.Fatal("expected error for dot path segment")
}
if !strings.Contains(err.Error(), "dot path segment") {
t.Errorf("expected dot path segment error, got: %v", err)
}
})
}
}

func TestAPI_RejectsQueryOrFragmentInPathTemplate(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

tests := []string{
"/v1/environments/{environment_id}/campaigns?include_archived=true",
"/v1/environments/{environment_id}/campaigns#section",
}

for _, path := range tests {
t.Run(path, func(t *testing.T) {
_, _, err := executeCommand("api", path,
"--api-url", server.URL,
"--params", `{"environment_id": "217838"}`)
if err == nil {
t.Fatal("expected error for query or fragment in path")
}
if !strings.Contains(err.Error(), "query or fragment") {
t.Errorf("expected query or fragment error, got: %v", err)
}
})
}
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/prime_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ You have access to `cio`, an agent-first CLI for Customer.io. Most commands retu
## Key Rules

1. Unless you are intentionally using `cio prime`, assume command output is JSON. Parse it, don't regex it.
2. Resource IDs are integers (but passed as strings in paths and params).
2. Path placeholders are passed as strings. Many IDs are numeric, but profile/object IDs can be string values such as `eea50d000102`; let the API own endpoint-specific ID semantics.
3. ALWAYS use `--jq` on read calls to limit output and save tokens.
4. ALWAYS use `--dry-run` before any mutating call to preview what will be sent.
5. Read the relevant skill (`cio skills read`) BEFORE making complex API calls — the API has non-obvious required fields, multi-step workflows, and silent failures.
Expand Down
39 changes: 37 additions & 2 deletions internal/validate/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func ValidateResourceID(id string) error {
}

for i, r := range id {
if r < 0x20 {
if r < 0x20 || r == 0x7F {
return &IDValidationError{
Field: "id",
Value: id,
Expand All @@ -28,7 +28,7 @@ func ValidateResourceID(id string) error {
return &IDValidationError{
Field: "id",
Value: id,
Reason: fmt.Sprintf("ID must be numeric, contains '%c'", banned),
Reason: fmt.Sprintf("ID contains reserved path character '%c'", banned),
}
}
}
Expand All @@ -46,6 +46,41 @@ func ValidateResourceID(id string) error {
return nil
}

// ValidatePathSegmentID validates that id is a non-empty, single path segment.
// It is intentionally semantic-free for generic API path placeholders, while
// still rejecting characters that could smuggle query fragments, traversal, or
// pre-encoded path data.
func ValidatePathSegmentID(id string) error {
if id == "" {
return &IDValidationError{Field: "id", Value: id, Reason: "ID must not be empty"}
}
if id == "." || id == ".." {
return &IDValidationError{Field: "id", Value: id, Reason: "ID must not be a dot path segment"}
}

for i, r := range id {
if r < 0x20 || r == 0x7F {
return &IDValidationError{
Field: "id",
Value: id,
Reason: fmt.Sprintf("ID contains control character at position %d (U+%04X)", i, r),
}
}
}

for _, banned := range invalidIDChars {
if strings.ContainsRune(id, banned) {
return &IDValidationError{
Field: "id",
Value: id,
Reason: fmt.Sprintf("ID contains reserved path character '%c'", banned),
}
}
}

return nil
}

// IDValidationError provides structured details for ID validation failures.
type IDValidationError struct {
Field string `json:"field"`
Expand Down
44 changes: 26 additions & 18 deletions skills/cio/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
---
name: cio
description: >
Customer.io CLI — use for any Customer.io, Journeys, or CDP Pipelines task.
Handles campaigns, broadcasts, segments, people, environments, subscription
topics, signing secrets, sources, destinations, track/identify events,
`sa_live_` tokens, and `fly.customer.io` / `cdp.customer.io` errors.
Trigger even when the user doesn't name the CLI.
Customer.io CLI — use for Customer.io, Journeys, or CDP Pipelines tasks,
including getting started phrases like "I want to build with Customer.io",
onboarding, signup, sending a first email, campaigns, broadcasts, segments,
people, environments, billing, pricing, plans, signing secrets, sources,
destinations, track/identify events, `sa_live_` tokens, and
`fly.customer.io` / `cdp.customer.io` errors, even when the user does not
name the CLI.
---

# Customer.io (`cio`)

Run `cio prime` first. It dumps the full LLM-ready reference commands,
flags, schemas, pagination, error shapes so you don't have to guess or
Run `cio prime` first. It dumps the full LLM-ready reference -- commands,
flags, schemas, pagination, error shapes -- so you don't have to guess or
spelunk through `--help`.

If `cio` is not installed, run `npm i -g @customerio/cli`. After installing,
run `cio prime`. If installation fails, fetch the README for other install
options:

```bash
cio prime
curl -fsSL https://raw.githubusercontent.com/customerio/cli/main/README.md
```

Everything below handles setup cases you might hit.
Expand All @@ -32,6 +38,18 @@ Examples: "I want to build with Customer.io", "help me set up
Customer.io", "I want to try Customer.io", "set up a new account",
"send my first email".

## If the user asks about plans, pricing, billing, or going live

Follow [billing.md](billing.md) when the user asks what plan they are on,
whether they need to pay, how to launch, how to unlock external sends, or
anything about billing, subscriptions, credits, invoices, receipts,
payment methods, or plan limits. This includes billing questions that come up
during onboarding.

Examples: "do I need to purchase a plan to go live?", "what does my plan
include?", "how much does this cost?", "can I send to real customers?",
"upgrade my plan", "buy credits", "what plan am I on?".

## If the user wants to integrate an SDK

Follow [integration.md](integration.md) only when the user
Expand All @@ -40,16 +58,6 @@ set up in-app messaging, push notifications, or connect their
codebase to Customer.io. The distinction is that they already have
a working account and want to wire it into code.

## If `cio` isn't installed

If `command -v cio` returns nothing, fetch the current install options
from the README (source of truth — this skill doesn't list them so it
can't rot), show the user the exact command, and ask before running it:

```bash
curl -fsSL https://raw.githubusercontent.com/customerio/cli/main/README.md
```

## If a command fails with an auth error

`cio auth login` auto-discovers the data center, so don't pass a region
Expand Down
Loading