mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
feat(hooks/builtin): ship pii-redactor exemplar — email + phone masking
First real builtin under Phase 04 infrastructure. Registers two DB rows
(user_prompt_submit + pre_tool_use) on startup, runs the embedded JS with
source-tier gate + dispatcher dotted-path allowlist.
- internal/hooks/builtin/pii-redactor.js (ES5.1, goja-compatible):
handle(event) returns updatedInput only when email or E.164-ish phone
actually matched. Redacts rawInput + toolInput.{command,query,content}.
- builtins.yaml: pii-redactor entry (version 1, priority 900, timeout 2s,
on_timeout=allow — missing redaction < blocking conversation).
- Drop _placeholder.js; pii-redactor.js is now the first real //go:embed match.
- Export builtin.Source(name) so handler tests load embedded JS without
going through Load()/cache state.
- Tests:
* loader_test: registry contains pii-redactor after Load().
* script_pii_redactor_test (handler-level, real goja runtime): email
masked in rawInput; +E164 phone masked; toolInput.command redacted,
toolInput.path left alone (not in mutable_fields); no PII → no
mutation; redact(redact(s)) == redact(s).
* dispatcher_test: 3 dotted-path walker cases (toolInput.command only
lets that key through; rawInput-only doesn't let forged toolInput
override original event keys; toolInput wildcard merges all keys).
Bench: ~242µs/op on 1 KiB Apple M4 Max, well under <1ms target.
This commit is contained in:
1 parent
8ee540dc7e
commit
a6592fd65f
7 files changed
+444
-8
No files matched your search
@@ -1,2 +0,0 @@
|
||||
// Placeholder to satisfy `//go:embed *.js` until the first real builtin
|
||||
// ships. Phase 05 adds pii-redactor.js and this file is removed.
|
||||
@@ -25,4 +25,24 @@
|
||||
# Phase 04 ships this registry empty. Phase 05 adds the first exemplar
|
||||
# (pii-redactor). When adding a builtin, also add the .js file + remove
|
||||
# the _placeholder.js once at least one real source_file exists.
|
||||
builtins: []
|
||||
builtins:
|
||||
- id: pii-redactor
|
||||
version: 1
|
||||
events: [user_prompt_submit, pre_tool_use]
|
||||
scope: global
|
||||
matcher: ""
|
||||
if_expr: ""
|
||||
timeout_ms: 2000
|
||||
# Fail-open on timeout: missing one redaction is less harmful than
|
||||
# blocking the whole conversation. Operators who want strict behavior
|
||||
# can toggle the row to matcher/disabled via config.
|
||||
on_timeout: allow
|
||||
# High priority so the redactor runs BEFORE other hooks see raw input.
|
||||
priority: 900
|
||||
mutable_fields:
|
||||
- rawInput
|
||||
- toolInput.command
|
||||
- toolInput.query
|
||||
- toolInput.content
|
||||
source_file: pii-redactor.js
|
||||
description: "Masks email addresses and E.164-ish phone numbers in user input and common tool-input fields."
|
||||
@@ -128,3 +128,11 @@ func source(file string) ([]byte, bool) {
|
||||
b, ok := sourceCache[file]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// Source returns the raw bytes of an embedded builtin source file without
|
||||
// going through Load()/the in-memory cache. Primarily for handler tests that
|
||||
// want to exercise the real embedded JS in isolation. Returns the usual
|
||||
// fs.ErrNotExist when the file is missing.
|
||||
func Source(name string) ([]byte, error) {
|
||||
return fs.ReadFile(name)
|
||||
}
|
||||
@@ -6,15 +6,42 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestLoad_EmptyYaml ensures the Phase 04 ship state (empty builtins list +
|
||||
// _placeholder.js) loads without error and leaves the registry empty.
|
||||
func TestLoad_EmptyYaml(t *testing.T) {
|
||||
// TestLoad_ShippedRegistry verifies the baked-in builtins parse clean at
|
||||
// build time. Phase 05 ships pii-redactor (2 events), so we assert the
|
||||
// registry contains at least that spec and that its embedded source file
|
||||
// loaded successfully.
|
||||
func TestLoad_ShippedRegistry(t *testing.T) {
|
||||
if err := Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := RegisteredSpecs(); len(got) != 0 {
|
||||
t.Errorf("registered=%d, want 0 (empty builtins.yaml)", len(got))
|
||||
specs := RegisteredSpecs()
|
||||
var pii *Spec
|
||||
for i := range specs {
|
||||
if specs[i].ID == "pii-redactor" {
|
||||
pii = &specs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if pii == nil {
|
||||
t.Fatalf("pii-redactor not in registry; have %v", specIDs(specs))
|
||||
}
|
||||
if pii.SourceFile != "pii-redactor.js" {
|
||||
t.Errorf("pii-redactor source_file=%q, want pii-redactor.js", pii.SourceFile)
|
||||
}
|
||||
if len(pii.Events) != 2 {
|
||||
t.Errorf("pii-redactor events=%v, want 2", pii.Events)
|
||||
}
|
||||
if src, ok := source("pii-redactor.js"); !ok || len(src) == 0 {
|
||||
t.Error("pii-redactor.js bytes not cached after Load")
|
||||
}
|
||||
}
|
||||
|
||||
func specIDs(ss []Spec) []string {
|
||||
out := make([]string, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
out = append(out, s.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBuiltinNamespace_Stable guards against a silent namespace change that
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Builtin: PII redactor (email + phone)
|
||||
// Version: 1 — matches builtins.yaml
|
||||
// Runs on: user_prompt_submit, pre_tool_use
|
||||
// Mutates: rawInput, toolInput.command, toolInput.query, toolInput.content
|
||||
//
|
||||
// ES5.1 only — goja runtime. No let/const, no arrow functions, no template literals.
|
||||
|
||||
var EMAIL_RE = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g;
|
||||
// E.164-ish: optional +, leading 1-9, 7-14 more digits; guarded so embedded
|
||||
// digits inside long tokens (ids, paths, filenames) are left alone.
|
||||
var PHONE_RE = /(^|[^0-9+])(\+?[1-9][0-9]{7,14})(?=[^0-9]|$)/g;
|
||||
|
||||
function redact(s) {
|
||||
if (typeof s !== "string" || s.length === 0) return s;
|
||||
var out = s.replace(EMAIL_RE, "[REDACTED_EMAIL]");
|
||||
out = out.replace(PHONE_RE, function (_m, lead) {
|
||||
return lead + "[REDACTED_PHONE]";
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function handle(event) {
|
||||
var changed = false;
|
||||
var updated = { toolInput: {} };
|
||||
|
||||
if (typeof event.rawInput === "string") {
|
||||
var r = redact(event.rawInput);
|
||||
if (r !== event.rawInput) {
|
||||
updated.rawInput = r;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var fields = ["command", "query", "content"];
|
||||
var ti = event.toolInput || {};
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var k = fields[i];
|
||||
var v = ti[k];
|
||||
if (typeof v === "string") {
|
||||
var rv = redact(v);
|
||||
if (rv !== v) {
|
||||
updated.toolInput[k] = rv;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { decision: "allow" };
|
||||
}
|
||||
var tiKeys = Object.keys(updated.toolInput);
|
||||
if (tiKeys.length === 0) {
|
||||
delete updated.toolInput;
|
||||
}
|
||||
return { decision: "allow", updatedInput: updated };
|
||||
}
|
||||
@@ -478,3 +478,148 @@ func TestDispatcher_ScriptMutation_UISourceDenied(t *testing.T) {
|
||||
t.Fatalf("non-builtin mutation leaked: UpdatedRawInput=%v", *r.UpdatedRawInput)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 05: dotted-path allowlist walker (exercises Phase-03 walker) ──────
|
||||
//
|
||||
// Phase 05 ships the pii-redactor whose mutable_fields are
|
||||
// [rawInput, toolInput.command, toolInput.query, toolInput.content].
|
||||
// These tests drive the dispatcher's applyBuiltinMutation via a mutating
|
||||
// handler + a custom lookup to prove the dotted-path and wildcard semantics
|
||||
// continue to hold with that specific allowlist. No actual JS runs here.
|
||||
|
||||
func TestDispatcher_Walker_DottedPathOnlyAllowedKeys(t *testing.T) {
|
||||
prev := installAllowlist(t, map[string][]string{
|
||||
"pii": {"toolInput.command"},
|
||||
})
|
||||
defer prev()
|
||||
|
||||
cfg := newBaseHook(hooks.HandlerScript, hooks.EventPreToolUse)
|
||||
cfg.Source = hooks.SourceBuiltin
|
||||
cfg.ID = allowlistID("pii")
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
|
||||
// Handler "tries" to mutate both command (allowed) AND path (not allowed).
|
||||
// The dispatcher must apply only the allowed key; path stays at its original
|
||||
// event value — NOT the handler's forged override.
|
||||
h := &mutatingHandler{updated: map[string]any{
|
||||
"toolInput": map[string]any{
|
||||
"command": "REDACTED",
|
||||
"path": "EVIL_OVERRIDE",
|
||||
},
|
||||
}}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h},
|
||||
})
|
||||
|
||||
r, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
ToolInput: map[string]any{"path": "/safe/path", "command": "old"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if r.UpdatedToolInput == nil {
|
||||
t.Fatal("walker dropped allowed key")
|
||||
}
|
||||
if got, _ := r.UpdatedToolInput["command"].(string); got != "REDACTED" {
|
||||
t.Errorf("command=%q, want REDACTED", got)
|
||||
}
|
||||
if got, _ := r.UpdatedToolInput["path"].(string); got != "/safe/path" {
|
||||
t.Errorf("disallowed key slipped through: path=%q, want /safe/path", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_Walker_RawInputOnly_IgnoresToolInput(t *testing.T) {
|
||||
prev := installAllowlist(t, map[string][]string{"raw": {"rawInput"}})
|
||||
defer prev()
|
||||
|
||||
cfg := newBaseHook(hooks.HandlerScript, hooks.EventUserPromptSubmit)
|
||||
cfg.Source = hooks.SourceBuiltin
|
||||
cfg.ID = allowlistID("raw")
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
|
||||
h := &mutatingHandler{updated: map[string]any{
|
||||
"rawInput": "redacted",
|
||||
"toolInput": map[string]any{"command": "should-be-stripped"},
|
||||
}}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h},
|
||||
})
|
||||
|
||||
r, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: "original",
|
||||
ToolInput: map[string]any{"command": "orig"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if r.UpdatedRawInput == nil || *r.UpdatedRawInput != "redacted" {
|
||||
t.Fatalf("UpdatedRawInput=%v, want 'redacted'", r.UpdatedRawInput)
|
||||
}
|
||||
// UpdatedToolInput may be non-nil (dispatcher returns the evMut toolInput
|
||||
// once any mutation happens), but each key must still equal its original
|
||||
// event value — the handler's forged "should-be-stripped" must NOT appear.
|
||||
if r.UpdatedToolInput != nil {
|
||||
if got, _ := r.UpdatedToolInput["command"].(string); got != "orig" {
|
||||
t.Errorf("toolInput.command leaked handler forge: got %q, want orig", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_Walker_ToolInputWildcard_MergesAll(t *testing.T) {
|
||||
prev := installAllowlist(t, map[string][]string{"wild": {"toolInput"}})
|
||||
defer prev()
|
||||
|
||||
cfg := newBaseHook(hooks.HandlerScript, hooks.EventPreToolUse)
|
||||
cfg.Source = hooks.SourceBuiltin
|
||||
cfg.ID = allowlistID("wild")
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
|
||||
h := &mutatingHandler{updated: map[string]any{
|
||||
"toolInput": map[string]any{"a": 1, "b": 2, "c": 3},
|
||||
}}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h},
|
||||
})
|
||||
|
||||
r, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
ToolInput: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
for _, k := range []string{"a", "b", "c"} {
|
||||
if _, ok := r.UpdatedToolInput[k]; !ok {
|
||||
t.Errorf("wildcard merge dropped key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installAllowlist swaps the dispatcher's per-id lookup for the duration of
|
||||
// a test. Keys in the map are resolved via allowlistID(name). Returns the
|
||||
// restore func; callers defer it.
|
||||
func installAllowlist(t *testing.T, m map[string][]string) func() {
|
||||
t.Helper()
|
||||
ids := map[uuid.UUID][]string{}
|
||||
for name, fields := range m {
|
||||
ids[allowlistID(name)] = fields
|
||||
}
|
||||
hooks.SetBuiltinAllowlistLookup(func(id uuid.UUID) []string { return ids[id] })
|
||||
return func() { hooks.SetBuiltinAllowlistLookup(nil) }
|
||||
}
|
||||
|
||||
func allowlistID(name string) uuid.UUID {
|
||||
// Stable deterministic UUID per label so cfg.ID matches the lookup.
|
||||
return uuid.NewSHA1(uuid.NameSpaceDNS, []byte("test.allowlist/"+name))
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks/builtin"
|
||||
)
|
||||
|
||||
// End-to-end tests for the Phase-05 pii-redactor builtin. These run the REAL
|
||||
// embedded JS through the REAL ScriptHandler (no stubs) so we catch any ES5.1
|
||||
// / goja regex compatibility issue at CI time.
|
||||
|
||||
func piiHook(t *testing.T, event hooks.HookEvent) hooks.HookConfig {
|
||||
t.Helper()
|
||||
src, err := builtin.Source("pii-redactor.js")
|
||||
if err != nil {
|
||||
t.Fatalf("load pii-redactor.js: %v", err)
|
||||
}
|
||||
return hooks.HookConfig{
|
||||
ID: builtin.BuiltinEventID("pii-redactor", string(event)),
|
||||
TenantID: hooks.SentinelTenantID,
|
||||
Event: event,
|
||||
HandlerType: hooks.HandlerScript,
|
||||
Scope: hooks.ScopeGlobal,
|
||||
Source: hooks.SourceBuiltin,
|
||||
Config: map[string]any{"source": string(src)},
|
||||
Metadata: map[string]any{"builtin": true, "version": 1},
|
||||
TimeoutMS: 2000,
|
||||
OnTimeout: hooks.DecisionAllow,
|
||||
Priority: 900,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func runPII(t *testing.T, ev hooks.Event) *hooks.ScriptResult {
|
||||
t.Helper()
|
||||
h := newTestHandler()
|
||||
res := &hooks.ScriptResult{}
|
||||
ctx, cancel := context.WithTimeout(hooks.WithScriptResult(context.Background(), res), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
dec, err := h.Execute(ctx, piiHook(t, ev.HookEvent), ev)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionAllow {
|
||||
t.Fatalf("decision=%v, want allow", dec)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestPIIRedactor_RawInputEmail(t *testing.T) {
|
||||
ev := hooks.Event{
|
||||
EventID: "e",
|
||||
TenantID: uuid.New(),
|
||||
AgentID: uuid.New(),
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: "email me at user@example.com please",
|
||||
}
|
||||
res := runPII(t, ev)
|
||||
got, _ := res.UpdatedInput["rawInput"].(string)
|
||||
if got != "email me at [REDACTED_EMAIL] please" {
|
||||
t.Fatalf("rawInput: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPIIRedactor_RawInputPhone(t *testing.T) {
|
||||
ev := hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: "call me at +14155551212 tonight",
|
||||
}
|
||||
res := runPII(t, ev)
|
||||
got, _ := res.UpdatedInput["rawInput"].(string)
|
||||
if !strings.Contains(got, "[REDACTED_PHONE]") || strings.Contains(got, "+14155551212") {
|
||||
t.Fatalf("rawInput: got %q; want phone masked", got)
|
||||
}
|
||||
}
|
||||
|
||||
// toolInput.command is in mutable_fields → gets redacted.
|
||||
func TestPIIRedactor_ToolInputCommandRedacted(t *testing.T) {
|
||||
ev := hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
ToolName: "shell",
|
||||
ToolInput: map[string]any{
|
||||
"command": "curl -d 'to=alice@co.com' https://svc",
|
||||
"path": "/etc/passwd",
|
||||
},
|
||||
}
|
||||
res := runPII(t, ev)
|
||||
ti, _ := res.UpdatedInput["toolInput"].(map[string]any)
|
||||
if ti == nil {
|
||||
t.Fatal("toolInput missing in updatedInput")
|
||||
}
|
||||
cmd, _ := ti["command"].(string)
|
||||
if !strings.Contains(cmd, "[REDACTED_EMAIL]") || strings.Contains(cmd, "alice@co.com") {
|
||||
t.Fatalf("command: got %q", cmd)
|
||||
}
|
||||
// toolInput.path is NOT in the JS's allowlist, so it stays unmutated.
|
||||
if _, ok := ti["path"]; ok {
|
||||
t.Fatalf("path should not be in updatedInput; got %v", ti["path"])
|
||||
}
|
||||
}
|
||||
|
||||
// No PII present → script returns {decision: "allow"} with no updatedInput.
|
||||
func TestPIIRedactor_NoPII_NoMutation(t *testing.T) {
|
||||
ev := hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: "how do I reverse a list in Python?",
|
||||
}
|
||||
res := runPII(t, ev)
|
||||
if len(res.UpdatedInput) > 0 {
|
||||
t.Fatalf("updatedInput non-empty on clean input: %v", res.UpdatedInput)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency: once redacted, running again must be a no-op.
|
||||
func TestPIIRedactor_Idempotent(t *testing.T) {
|
||||
once := hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: "user@example.com and +14155551212",
|
||||
}
|
||||
r1 := runPII(t, once)
|
||||
redacted, _ := r1.UpdatedInput["rawInput"].(string)
|
||||
if redacted == "" {
|
||||
t.Fatal("first pass produced no mutation")
|
||||
}
|
||||
twice := hooks.Event{
|
||||
EventID: "e2",
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: redacted,
|
||||
}
|
||||
r2 := runPII(t, twice)
|
||||
if len(r2.UpdatedInput) > 0 {
|
||||
t.Fatalf("second pass mutated already-redacted text: %v", r2.UpdatedInput)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPIIRedactor_1KiB sanity-checks the <1ms median target. Informational.
|
||||
func BenchmarkPIIRedactor_1KiB(b *testing.B) {
|
||||
src, err := builtin.Source("pii-redactor.js")
|
||||
if err != nil {
|
||||
b.Fatalf("load: %v", err)
|
||||
}
|
||||
cfg := hooks.HookConfig{
|
||||
ID: builtin.BuiltinEventID("pii-redactor", "user_prompt_submit"),
|
||||
TenantID: hooks.SentinelTenantID,
|
||||
Event: hooks.EventUserPromptSubmit,
|
||||
HandlerType: hooks.HandlerScript,
|
||||
Source: hooks.SourceBuiltin,
|
||||
Config: map[string]any{"source": string(src)},
|
||||
Version: 1,
|
||||
Enabled: true,
|
||||
}
|
||||
// ~1 KiB mostly-clean text with one email at the tail.
|
||||
base := strings.Repeat("the quick brown fox jumps over the lazy dog. ", 24) // 1080 bytes
|
||||
payload := base[:1010] + " u@example.com"
|
||||
ev := hooks.Event{
|
||||
EventID: "bench",
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
RawInput: payload,
|
||||
}
|
||||
|
||||
h := NewScriptHandler(8, 4, 64)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
res := &hooks.ScriptResult{}
|
||||
ctx := hooks.WithScriptResult(context.Background(), res)
|
||||
if _, err := h.Execute(ctx, cfg, ev); err != nil {
|
||||
b.Fatalf("exec: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user