Skip to content

Commit c9f7fe5

Browse files
committed
feat(pii): add deterministic redaction pipeline with gated overrides
Implement a full PII redaction system across CLI read outputs, with configurable modes, per-request override controls, deterministic identity replacement, and comprehensive docs/tests. Highlights: - Add new redaction engine in internal/pii: - policy handling (off|customers|all) - deterministic pseudonymization (with optional HS_PII_SECRET) - layered free-text regex redaction - recursive JSON walker for structured + text fields - Add config/env support: - pii_mode / HS_PII_MODE - pii_allow_unredacted / HS_PII_ALLOW_UNREDACTED - Add global inbox flag: - --unredacted (strictly gated by allow setting when redaction is enabled) - Integrate redaction into command output paths: - conversations, threads, customers, users, teams, organizations, tools - table/csv/json/json-full - protect threads source and threads source-rfc822 - Add command helpers for effective mode resolution and safe raw output wrapping - Expand tests: - new internal/pii unit tests - config tests for new keys/validation - cmd tests for redaction behavior and override gating - Update README: - dedicated PII Redaction Pipeline section - trust/security callout near top - usage + disable/unset examples (--pii-mode off, --pii-allow-unredacted=false)
1 parent 3b76618 commit c9f7fe5

24 files changed

+1475
-55
lines changed

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
A command-line interface for the [HelpScout](https://www.helpscout.com/) API. Manage mailboxes, conversations, customers, tags, users, workflows, and webhooks from the terminal.
44

5+
> **Built for shared and AI-assisted workflows**
6+
> hs-cli ships with a deterministic, layered PII redaction pipeline (structured fields + free-text + source payload protection), plus strict per-command override controls.
7+
> See [PII Redaction Pipeline](#pii-redaction-pipeline).
8+
59
## Install
610

711
```bash
@@ -49,13 +53,16 @@ This prompts for your Client ID and Secret, validates them against the API, and
4953
```bash
5054
hs inbox config set --client-id your-client-id --client-secret your-client-secret
5155
hs inbox config set --default-mailbox 12345 --format json
56+
hs inbox config set --pii-mode customers --pii-allow-unredacted
5257
```
5358

5459
### Environment variables
5560

5661
```bash
5762
export HS_CLIENT_ID=your-client-id
5863
export HS_CLIENT_SECRET=your-client-secret
64+
export HS_PII_MODE=customers
65+
export HS_PII_ALLOW_UNREDACTED=1
5966
```
6067

6168
### Config file
@@ -67,6 +74,8 @@ client_id: your-client-id
6774
client_secret: your-client-secret
6875
default_mailbox: 12345
6976
format: table
77+
pii_mode: off
78+
pii_allow_unredacted: false
7079
```
7180
7281
**Credential resolution order:** environment variables > OS keyring > config file.
@@ -93,9 +102,79 @@ hs inbox auth logout
93102
| `--page` | Page number | `1` |
94103
| `--per-page` | Results per page | `25` |
95104
| `--debug` | Log HTTP requests/responses to `hs-debug.log` | `false` |
105+
| `--unredacted` | Disable PII redaction for this command when allowed by config/env | `false` |
96106

97107
The `--format` flag can also be set permanently via the `format` key in the config file, or the `HS_FORMAT` environment variable.
98108

109+
## PII Redaction Pipeline
110+
111+
hs-cli includes a production-focused PII redaction system designed for shared terminals, MCP/LLM workflows, and incident-safe exports.
112+
113+
### Why this matters
114+
115+
- Prevents accidental exposure of customer/agent data in terminal output.
116+
- Keeps output useful for debugging and triage by preserving structure and readability.
117+
- Gives operators explicit control: strict defaults with an auditable per-command escape hatch.
118+
119+
### Depth of protection
120+
121+
Redaction is applied in layered stages:
122+
123+
1. Structured identity redaction
124+
- Redacts known person/customer/user fields (names, emails, phones) across table, csv, json, and json-full outputs.
125+
- Covers nested payloads through a JSON walker.
126+
127+
2. Free-text redaction
128+
- Scans thread/content text (`body`, `action`, `subject`, `preview`, source payloads) with a broad regex pipeline.
129+
- Detects and replaces common PII classes such as emails, phones, SSNs, card-like values, addresses, IPs, URLs, and person-name patterns.
130+
131+
3. Raw source protection
132+
- `threads source` and `threads source-rfc822` are redacted when PII mode is enabled.
133+
134+
### Deterministic anonymization and cache behavior
135+
136+
- Replacements are deterministic: the same input maps to the same pseudonym across commands and runs.
137+
- `HS_PII_SECRET` (optional) adds a secret salt for stronger pseudonym generation.
138+
- Without `HS_PII_SECRET`, deterministic hashing is still used (stable fallback).
139+
- The engine also keeps an in-memory per-command cache so repeated values in the same output are co-derived consistently and processed efficiently.
140+
141+
### Modes and override controls
142+
143+
PII mode:
144+
145+
- `off`: no redaction (default)
146+
- `customers`: redact customer identities
147+
- `all`: redact customer + user identities
148+
149+
Override policy:
150+
151+
- `--unredacted` disables redaction for a single command
152+
- It is only allowed when `pii_allow_unredacted: true` or `HS_PII_ALLOW_UNREDACTED=1`
153+
- If overrides are disallowed and redaction is enabled, the command fails fast with a clear error
154+
155+
### Quick-start examples
156+
157+
```bash
158+
# Enable customer-only redaction globally
159+
hs inbox config set --pii-mode customers
160+
161+
# Allow temporary per-command bypasses (for incident response/debugging)
162+
hs inbox config set --pii-allow-unredacted
163+
164+
# Run safely redacted output
165+
hs inbox conversations list --format json
166+
hs inbox conversations threads source-rfc822 12345 67890
167+
168+
# Temporarily bypass redaction for one command (only if allowed)
169+
hs inbox --unredacted conversations get 12345 --format json-full
170+
171+
# Disable per-command bypass again
172+
hs inbox config set --pii-allow-unredacted=false
173+
174+
# Fully disable redaction
175+
hs inbox config set --pii-mode off --pii-allow-unredacted=false
176+
```
177+
99178
## Commands
100179

101180
Inbox API commands are namespaced under `hs inbox ...`.
@@ -107,6 +186,12 @@ Inbox API commands are namespaced under `hs inbox ...`.
107186
hs inbox config set --client-id xxx --client-secret yyy
108187
hs inbox config set --default-mailbox 12345
109188
hs inbox config set --format json
189+
hs inbox config set --pii-mode customers
190+
hs inbox config set --pii-allow-unredacted
191+
192+
# Disable/unset PII features explicitly
193+
hs inbox config set --pii-allow-unredacted=false
194+
hs inbox config set --pii-mode off --pii-allow-unredacted=false
110195

111196
# View all config values
112197
hs inbox config get
@@ -126,6 +211,8 @@ hs inbox config path
126211
| `--client-secret` | string | HelpScout Client Secret |
127212
| `--default-mailbox` | int | Default mailbox ID |
128213
| `--format` | string | Output format: table, json, json-full, csv |
214+
| `--pii-mode` | string | PII redaction mode: off, customers, all |
215+
| `--pii-allow-unredacted` | bool | Allow per-request `--unredacted` override |
129216

130217
### Self-update
131218

@@ -934,6 +1021,8 @@ Use `hs inbox config set` to write values, `hs inbox config get` to read them, a
9341021
| `client_secret` | `--client-secret` | HelpScout Client Secret |
9351022
| `default_mailbox` | `--default-mailbox` | Auto-filter conversations to this mailbox |
9361023
| `format` | `--format` | Output format: `table`, `json`, `json-full`, or `csv` |
1024+
| `pii_mode` | `--pii-mode` | PII redaction mode: `off`, `customers`, `all` |
1025+
| `pii_allow_unredacted` | `--pii-allow-unredacted` | Allow `--unredacted` to bypass redaction per request |
9371026

9381027
### Environment variables
9391028

@@ -942,6 +1031,9 @@ Use `hs inbox config set` to write values, `hs inbox config get` to read them, a
9421031
| `HS_CLIENT_ID` | `client_id` |
9431032
| `HS_CLIENT_SECRET` | `client_secret` |
9441033
| `HS_FORMAT` | `format` |
1034+
| `HS_PII_MODE` | `pii_mode` |
1035+
| `HS_PII_ALLOW_UNREDACTED` | `pii_allow_unredacted` |
1036+
| `HS_PII_SECRET` | Optional secret salt for deterministic pseudonyms |
9451037
| `HS_INBOX_PERMISSIONS` | `inbox_permissions` |
9461038
| `HS_NO_UPDATE_CHECK` | Disable daily update check (`1`) |
9471039

internal/cmd/cmd_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func saveRestore(t *testing.T) {
4343
origCfgPath := cfgPath
4444
origApiClient := apiClient
4545
origFormat := format
46+
origUnredacted := unredacted
4647
origNoPaginate := noPaginate
4748
origPage := page
4849
origPerPage := perPage
@@ -54,6 +55,8 @@ func saveRestore(t *testing.T) {
5455
origSetClientSecret := setClientSecret
5556
origSetDefaultMailbox := setDefaultMailbox
5657
origSetFormat := setFormat
58+
origSetPIIMode := setPIIMode
59+
origSetPIIAllowRaw := setPIIAllowRaw
5760

5861
selfupdate.DirOverride = t.TempDir()
5962

@@ -62,6 +65,7 @@ func saveRestore(t *testing.T) {
6265
cfgPath = origCfgPath
6366
apiClient = origApiClient
6467
format = origFormat
68+
unredacted = origUnredacted
6569
noPaginate = origNoPaginate
6670
page = origPage
6771
perPage = origPerPage
@@ -73,6 +77,8 @@ func saveRestore(t *testing.T) {
7377
setClientSecret = origSetClientSecret
7478
setDefaultMailbox = origSetDefaultMailbox
7579
setFormat = origSetFormat
80+
setPIIMode = origSetPIIMode
81+
setPIIAllowRaw = origSetPIIAllowRaw
7682
configSetCmd.Flags().VisitAll(func(f *pflag.Flag) {
7783
f.Changed = false
7884
})

internal/cmd/config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import (
66
"github.com/spf13/cobra"
77

88
"github.com/operator-kit/hs-cli/internal/config"
9+
"github.com/operator-kit/hs-cli/internal/pii"
910
)
1011

1112
var (
1213
setClientID string
1314
setClientSecret string
1415
setDefaultMailbox int
1516
setFormat string
17+
setPIIMode string
18+
setPIIAllowRaw bool
1619

1720
configCmd *cobra.Command
1821
configSetCmd *cobra.Command
@@ -42,6 +45,8 @@ func newConfigSetCmd() *cobra.Command {
4245
cmd.Flags().StringVar(&setClientSecret, "client-secret", "", "HelpScout Client Secret")
4346
cmd.Flags().IntVar(&setDefaultMailbox, "default-mailbox", 0, "default mailbox ID")
4447
cmd.Flags().StringVar(&setFormat, "format", "", "output format: table|json|csv")
48+
cmd.Flags().StringVar(&setPIIMode, "pii-mode", "", "PII redaction mode: off|customers|all")
49+
cmd.Flags().BoolVar(&setPIIAllowRaw, "pii-allow-unredacted", false, "allow per-request --unredacted override")
4550
return cmd
4651
}
4752

@@ -89,6 +94,15 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
8994
if cmd.Flags().Changed("format") {
9095
existing.Format = setFormat
9196
}
97+
if cmd.Flags().Changed("pii-mode") {
98+
if !pii.IsValidMode(setPIIMode) {
99+
return fmt.Errorf("invalid --pii-mode: %q (expected off|customers|all)", setPIIMode)
100+
}
101+
existing.PIIMode = setPIIMode
102+
}
103+
if cmd.Flags().Changed("pii-allow-unredacted") {
104+
existing.PIIAllowUnredacted = setPIIAllowRaw
105+
}
92106

93107
if err := config.Save(path, existing); err != nil {
94108
return err
@@ -112,6 +126,8 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
112126
fmt.Fprintf(out, "client-secret: %s\n", c.ClientSecret)
113127
fmt.Fprintf(out, "default-mailbox: %d\n", c.DefaultMailbox)
114128
fmt.Fprintf(out, "format: %s\n", c.Format)
129+
fmt.Fprintf(out, "pii-mode: %s\n", c.PIIMode)
130+
fmt.Fprintf(out, "pii-allow-unredacted: %t\n", c.PIIAllowUnredacted)
115131
return nil
116132
}
117133

@@ -124,6 +140,10 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
124140
fmt.Fprintf(out, "%d\n", c.DefaultMailbox)
125141
case "format":
126142
fmt.Fprintf(out, "%s\n", c.Format)
143+
case "pii-mode":
144+
fmt.Fprintf(out, "%s\n", c.PIIMode)
145+
case "pii-allow-unredacted":
146+
fmt.Fprintf(out, "%t\n", c.PIIAllowUnredacted)
127147
default:
128148
return fmt.Errorf("unknown config key: %s", args[0])
129149
}

internal/cmd/config_test.go

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ func TestConfigSet(t *testing.T) {
2222
t.Setenv("HS_CLIENT_ID", "")
2323
t.Setenv("HS_CLIENT_SECRET", "")
2424
t.Setenv("HS_FORMAT", "")
25+
t.Setenv("HS_PII_MODE", "")
26+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
2527

2628
buf := new(bytes.Buffer)
2729
rootCmd.SetOut(buf)
28-
rootCmd.SetArgs([]string{"inbox", "config", "set", "--client-id", "myid", "--client-secret", "mysecret", "--default-mailbox", "42", "--format", "json"})
30+
rootCmd.SetArgs([]string{"inbox", "config", "set", "--client-id", "myid", "--client-secret", "mysecret", "--default-mailbox", "42", "--format", "json", "--pii-mode", "customers", "--pii-allow-unredacted"})
2931
require.NoError(t, rootCmd.Execute())
3032

3133
assert.Contains(t, buf.String(), "Config saved")
@@ -36,6 +38,8 @@ func TestConfigSet(t *testing.T) {
3638
assert.Equal(t, "mysecret", loaded.ClientSecret)
3739
assert.Equal(t, 42, loaded.DefaultMailbox)
3840
assert.Equal(t, "json", loaded.Format)
41+
assert.Equal(t, "customers", loaded.PIIMode)
42+
assert.True(t, loaded.PIIAllowUnredacted)
3943
}
4044

4145
func TestConfigSet_Partial(t *testing.T) {
@@ -49,12 +53,15 @@ func TestConfigSet_Partial(t *testing.T) {
4953
t.Setenv("HS_CLIENT_ID", "")
5054
t.Setenv("HS_CLIENT_SECRET", "")
5155
t.Setenv("HS_FORMAT", "")
56+
t.Setenv("HS_PII_MODE", "")
57+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
5258

5359
require.NoError(t, config.Save(cfgFile, &config.Config{
5460
ClientID: "original-id",
5561
ClientSecret: "original-secret",
5662
DefaultMailbox: 10,
5763
Format: "table",
64+
PIIMode: "off",
5865
}))
5966

6067
buf := new(bytes.Buffer)
@@ -68,6 +75,7 @@ func TestConfigSet_Partial(t *testing.T) {
6875
assert.Equal(t, "original-secret", loaded.ClientSecret)
6976
assert.Equal(t, 10, loaded.DefaultMailbox)
7077
assert.Equal(t, "table", loaded.Format)
78+
assert.Equal(t, "off", loaded.PIIMode)
7179
}
7280

7381
func TestConfigSet_MutualFields(t *testing.T) {
@@ -81,6 +89,8 @@ func TestConfigSet_MutualFields(t *testing.T) {
8189
t.Setenv("HS_CLIENT_ID", "")
8290
t.Setenv("HS_CLIENT_SECRET", "")
8391
t.Setenv("HS_FORMAT", "")
92+
t.Setenv("HS_PII_MODE", "")
93+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
8494

8595
buf := new(bytes.Buffer)
8696
rootCmd.SetOut(buf)
@@ -104,12 +114,16 @@ func TestConfigGet(t *testing.T) {
104114
t.Setenv("HS_CLIENT_ID", "")
105115
t.Setenv("HS_CLIENT_SECRET", "")
106116
t.Setenv("HS_FORMAT", "")
117+
t.Setenv("HS_PII_MODE", "")
118+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
107119

108120
require.NoError(t, config.Save(cfgFile, &config.Config{
109-
ClientID: "myid",
110-
ClientSecret: "mysecret",
111-
DefaultMailbox: 42,
112-
Format: "json",
121+
ClientID: "myid",
122+
ClientSecret: "mysecret",
123+
DefaultMailbox: 42,
124+
Format: "json",
125+
PIIMode: "all",
126+
PIIAllowUnredacted: true,
113127
}))
114128

115129
buf := new(bytes.Buffer)
@@ -122,6 +136,8 @@ func TestConfigGet(t *testing.T) {
122136
assert.Contains(t, output, "client-secret: mysecret")
123137
assert.Contains(t, output, "default-mailbox: 42")
124138
assert.Contains(t, output, "format: json")
139+
assert.Contains(t, output, "pii-mode: all")
140+
assert.Contains(t, output, "pii-allow-unredacted: true")
125141
}
126142

127143
func TestConfigGet_SingleKey(t *testing.T) {
@@ -135,6 +151,8 @@ func TestConfigGet_SingleKey(t *testing.T) {
135151
t.Setenv("HS_CLIENT_ID", "")
136152
t.Setenv("HS_CLIENT_SECRET", "")
137153
t.Setenv("HS_FORMAT", "")
154+
t.Setenv("HS_PII_MODE", "")
155+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
138156

139157
require.NoError(t, config.Save(cfgFile, &config.Config{
140158
ClientID: "myid",
@@ -164,3 +182,45 @@ func TestConfigPath(t *testing.T) {
164182

165183
assert.Equal(t, cfgFile+"\n", buf.String())
166184
}
185+
186+
func TestConfigGet_SinglePIIModeKey(t *testing.T) {
187+
saveRestore(t)
188+
versionStr = "dev"
189+
190+
dir := t.TempDir()
191+
cfgFile := filepath.Join(dir, "config.yaml")
192+
cfgPath = cfgFile
193+
194+
t.Setenv("HS_CLIENT_ID", "")
195+
t.Setenv("HS_CLIENT_SECRET", "")
196+
t.Setenv("HS_FORMAT", "")
197+
t.Setenv("HS_PII_MODE", "")
198+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
199+
200+
require.NoError(t, config.Save(cfgFile, &config.Config{
201+
PIIMode: "customers",
202+
}))
203+
204+
buf := new(bytes.Buffer)
205+
rootCmd.SetOut(buf)
206+
rootCmd.SetArgs([]string{"inbox", "config", "get", "pii-mode"})
207+
require.NoError(t, rootCmd.Execute())
208+
209+
assert.Equal(t, "customers\n", buf.String())
210+
}
211+
212+
func TestConfigSet_InvalidPIIMode(t *testing.T) {
213+
saveRestore(t)
214+
versionStr = "dev"
215+
216+
cfgPath = filepath.Join(t.TempDir(), "config.yaml")
217+
t.Setenv("HS_PII_MODE", "")
218+
t.Setenv("HS_PII_ALLOW_UNREDACTED", "")
219+
220+
buf := new(bytes.Buffer)
221+
rootCmd.SetOut(buf)
222+
rootCmd.SetArgs([]string{"inbox", "config", "set", "--pii-mode", "bad"})
223+
err := rootCmd.Execute()
224+
require.Error(t, err)
225+
assert.Contains(t, err.Error(), "invalid --pii-mode")
226+
}

0 commit comments

Comments
 (0)