Add mroberts plugin marketplace with guards plugin
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||||
|
"name": "mroberts",
|
||||||
|
"description": "Personal Claude Code plugin marketplace.",
|
||||||
|
"owner": {
|
||||||
|
"name": "Malcolm Roberts",
|
||||||
|
"url": "https://git.mroberts.dev/mroberts"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "guards",
|
||||||
|
"description": "jj-only version control, no Claude attribution, build+test gate on push, secret scrubbing on writes.",
|
||||||
|
"source": "./plugins/guards",
|
||||||
|
"category": "productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# mroberts — Claude Code plugin marketplace
|
||||||
|
|
||||||
|
Personal marketplace hosting multiple Claude Code plugins.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin marketplace add ssh://[email protected]/mroberts/claude-plugin.git
|
||||||
|
claude plugin install guards@mroberts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude-plugin/marketplace.json # index — every plugin must be listed here
|
||||||
|
plugins/
|
||||||
|
guards/
|
||||||
|
.claude-plugin/plugin.json
|
||||||
|
hooks/hooks.json
|
||||||
|
hooks/bash-guard.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a plugin
|
||||||
|
|
||||||
|
1. `mkdir -p plugins/<name>/.claude-plugin`
|
||||||
|
2. Write `plugins/<name>/.claude-plugin/plugin.json` with `name`, `version`, `description`.
|
||||||
|
3. Add components: `hooks/hooks.json`, `commands/`, `agents/`, `skills/` as needed.
|
||||||
|
4. Append an entry to `plugins[]` in `.claude-plugin/marketplace.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "<name>", "source": "./plugins/<name>", "description": "...", "category": "productivity" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin absent from `plugins[]` is not installable, regardless of its directory.
|
||||||
|
|
||||||
|
5. Push, then `claude plugin marketplace update mroberts`.
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
| Plugin | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `guards` | Enforces the hard constraints in `~/.claude/CLAUDE.md` — jj-only VCS, no Claude attribution, build+test gate before push/tag, secret scrubbing on writes. |
|
||||||
|
|
||||||
|
## Note on `guards`
|
||||||
|
|
||||||
|
`hooks/bash-guard.mjs` is referenced via `${CLAUDE_PLUGIN_ROOT}` and travels with the
|
||||||
|
plugin. `shush` is referenced by absolute path (`/home/mroberts/.local/bin/shush`)
|
||||||
|
because it is a separately installed binary, not a repo file — that path is
|
||||||
|
machine-specific and will need changing on another host.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "guards",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Personal enforcement hooks: jj-only version control, no Claude attribution, build+test gate on push, and secret scrubbing on file writes.",
|
||||||
|
"author": {
|
||||||
|
"name": "Malcolm Roberts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// PreToolUse:Bash guard — mechanically enforces the hard constraints in CLAUDE.md.
|
||||||
|
// 1. mutating git commands -> denied, with the jj equivalent handed back
|
||||||
|
// 2. Claude attribution -> denied in commit / PR text
|
||||||
|
// 3. push or tag -> build + test + lint gate, secret scan, workflow lint
|
||||||
|
//
|
||||||
|
// Denials return permissionDecision:"deny"; its reason reaches the model, so every
|
||||||
|
// block teaches the correct command instead of just failing.
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, readdirSync, appendFileSync } from 'fs';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { join, dirname, basename } from 'path';
|
||||||
|
|
||||||
|
// NOTE: blocking works only when Claude Code runs WITHOUT --dangerously-skip-permissions.
|
||||||
|
// Under that flag the hook still executes but every decision below is discarded.
|
||||||
|
const deny = (reason) => {
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: reason,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let input;
|
||||||
|
try {
|
||||||
|
input = JSON.parse(readFileSync('/dev/stdin', 'utf8'));
|
||||||
|
} catch {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const command = input?.tool_input?.command ?? '';
|
||||||
|
if (!command.trim()) process.exit(0);
|
||||||
|
|
||||||
|
// TEMP diagnostic — remove once permission-mode behaviour is settled.
|
||||||
|
try {
|
||||||
|
appendFileSync('/tmp/bash-guard-mode.log',
|
||||||
|
`mode=${input?.permission_mode ?? '<absent>'} cmd=${command.slice(0, 50)}\n`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- segmenting
|
||||||
|
|
||||||
|
// Quote-aware split on shell separators, so a separator inside a commit message
|
||||||
|
// cannot produce a bogus segment and a false denial.
|
||||||
|
function segments(cmd) {
|
||||||
|
const out = [];
|
||||||
|
let cur = '', quote = null;
|
||||||
|
for (let i = 0; i < cmd.length; i++) {
|
||||||
|
const c = cmd[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === '\\' && quote === '"') { cur += c + (cmd[++i] ?? ''); continue; }
|
||||||
|
if (c === quote) quote = null;
|
||||||
|
cur += c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'") { quote = c; cur += c; continue; }
|
||||||
|
if (c === '\n' || c === ';' || c === '|' || c === '&') {
|
||||||
|
if ((c === '|' || c === '&') && cmd[i + 1] === c) i++;
|
||||||
|
out.push(cur); cur = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur += c;
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
return out.map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First real word, skipping `env`, leading VAR=... assignments and sudo.
|
||||||
|
function head(seg) {
|
||||||
|
const toks = seg.split(/\s+/).filter(Boolean);
|
||||||
|
let i = 0;
|
||||||
|
while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]) || toks[i] === 'env' || toks[i] === 'sudo')) i++;
|
||||||
|
return { cmd: toks[i] ? basename(toks[i]) : '', args: toks.slice(i + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const segs = segments(command);
|
||||||
|
|
||||||
|
// ------------------------------------------------- 1. mutating git -> jj map
|
||||||
|
|
||||||
|
const GIT_TO_JJ = {
|
||||||
|
commit: 'jj describe -m "msg" (then `jj new` to begin the next change)',
|
||||||
|
add: 'nothing — jj tracks working-copy changes automatically',
|
||||||
|
rm: 'just delete the file — jj picks it up automatically',
|
||||||
|
mv: 'just move the file — jj picks it up automatically',
|
||||||
|
push: 'jj git push',
|
||||||
|
pull: 'jj git fetch (then `jj rebase -d <bookmark>`)',
|
||||||
|
fetch: 'jj git fetch',
|
||||||
|
clone: 'jj git clone <url>',
|
||||||
|
init: 'jj git init --colocate',
|
||||||
|
checkout: 'jj new <rev> (start new change) or `jj edit <rev>` (edit existing)',
|
||||||
|
switch: 'jj new <rev> or `jj edit <rev>`',
|
||||||
|
reset: 'jj restore (discard working-copy changes) or `jj abandon <rev>`',
|
||||||
|
restore: 'jj restore <path>',
|
||||||
|
revert: 'jj backout -r <rev>',
|
||||||
|
merge: 'jj new <rev1> <rev2> (creates a merge change)',
|
||||||
|
rebase: 'jj rebase -d <dest>',
|
||||||
|
'cherry-pick': 'jj duplicate <rev> then `jj rebase -d <dest>`',
|
||||||
|
stash: 'nothing — jj auto-snapshots; `jj new` sets work aside',
|
||||||
|
branch: 'jj bookmark set <name> (jj calls branches "bookmarks")',
|
||||||
|
clean: 'jj restore (or delete the untracked files directly)',
|
||||||
|
apply: 'apply the patch to the working copy, then `jj describe`',
|
||||||
|
am: 'apply the patch to the working copy, then `jj describe`',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const seg of segs) {
|
||||||
|
const { cmd, args } = head(seg);
|
||||||
|
if (cmd !== 'git') continue; // `jj git push` heads on `jj`, so it passes through
|
||||||
|
const verb = args.find((a) => !a.startsWith('-'));
|
||||||
|
const fix = GIT_TO_JJ[verb];
|
||||||
|
if (fix) {
|
||||||
|
deny(
|
||||||
|
`BLOCKED: \`git ${verb}\` — this repo is managed with jj (CLAUDE.md: version control is jj only; raw git can corrupt jj state).\n\n` +
|
||||||
|
`Use instead:\n ${fix}\n\n` +
|
||||||
|
`Read-only git (status, log, diff, show, rev-parse) is still allowed. New bookmarks follow \`malcolm/change-being-made\`.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------- 2. no Claude attribution, ever
|
||||||
|
|
||||||
|
const ATTRIBUTION = [
|
||||||
|
/co-?authored-?by:\s*claude/i,
|
||||||
|
/generated with \[?claude/i,
|
||||||
|
/🤖\s*generated with/i,
|
||||||
|
/created by claude/i,
|
||||||
|
/claude(-code)?\s*<noreply@anthropic\.com>/i,
|
||||||
|
];
|
||||||
|
const AUTHORS_TEXT = /\b(jj\s+(describe|commit|new|split)|gh\s+pr\s+(create|edit|comment)|gh\s+release\s+create)\b/;
|
||||||
|
|
||||||
|
if (AUTHORS_TEXT.test(command)) {
|
||||||
|
const hit = ATTRIBUTION.find((re) => re.test(command));
|
||||||
|
if (hit) {
|
||||||
|
deny(
|
||||||
|
'BLOCKED: Claude attribution in a commit message or PR body (CLAUDE.md: no Claude attribution anywhere).\n\n' +
|
||||||
|
'Remove the "Generated with Claude Code" line and any `Co-Authored-By: Claude` trailer, then retry.\n' +
|
||||||
|
'Malcolm adds attribution manually when it is warranted — never add it unprompted.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- 3. push / tag gate
|
||||||
|
|
||||||
|
const isPush = /\bjj\s+git\s+push\b/.test(command);
|
||||||
|
const isTag = segs.some((s) => {
|
||||||
|
const { cmd, args } = head(s);
|
||||||
|
return (cmd === 'git' && args[0] === 'tag') || (cmd === 'jj' && args[0] === 'tag');
|
||||||
|
});
|
||||||
|
if (!isPush && !isTag) process.exit(0);
|
||||||
|
|
||||||
|
// Repo root: CLAUDE_PROJECT_DIR is authoritative; otherwise walk up for a workspace marker.
|
||||||
|
function repoRoot() {
|
||||||
|
if (process.env.CLAUDE_PROJECT_DIR && existsSync(process.env.CLAUDE_PROJECT_DIR)) {
|
||||||
|
return process.env.CLAUDE_PROJECT_DIR;
|
||||||
|
}
|
||||||
|
let dir = input?.cwd || process.cwd();
|
||||||
|
for (;;) {
|
||||||
|
if (existsSync(join(dir, '.jj')) || existsSync(join(dir, '.git'))) return dir;
|
||||||
|
const up = dirname(dir);
|
||||||
|
if (up === dir) return input?.cwd || process.cwd();
|
||||||
|
dir = up;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ROOT = repoRoot();
|
||||||
|
|
||||||
|
const SKIP = new Set([
|
||||||
|
'node_modules', '.git', '.jj', 'target', 'bin', 'obj', 'dist', 'build',
|
||||||
|
'.venv', 'venv', '__pycache__', 'vendor', '.next', 'out', 'packages',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Marker files are searched to depth 2 so monorepo layouts (back-end/, front-end/,
|
||||||
|
// services/*) are found without hardcoding any particular directory naming.
|
||||||
|
function findProjects(root, depth = 0) {
|
||||||
|
let found = [];
|
||||||
|
let entries;
|
||||||
|
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return found; }
|
||||||
|
const names = entries.filter((e) => e.isFile()).map((e) => e.name);
|
||||||
|
|
||||||
|
const has = (re) => names.some((n) => re.test(n));
|
||||||
|
if (has(/\.(sln|slnx)$/)) found.push({ kind: 'dotnet', dir: root });
|
||||||
|
else if (has(/\.(csproj|fsproj|vbproj)$/)) found.push({ kind: 'dotnet', dir: root });
|
||||||
|
if (names.includes('package.json')) found.push({ kind: 'node', dir: root });
|
||||||
|
if (names.includes('go.mod')) found.push({ kind: 'go', dir: root });
|
||||||
|
if (names.includes('Cargo.toml')) found.push({ kind: 'rust', dir: root });
|
||||||
|
if (names.includes('pyproject.toml') || names.includes('setup.py')) found.push({ kind: 'python', dir: root });
|
||||||
|
if (names.includes('CMakeLists.txt')) found.push({ kind: 'cmake', dir: root });
|
||||||
|
else if (names.includes('Makefile') || names.includes('makefile')) found.push({ kind: 'make', dir: root });
|
||||||
|
|
||||||
|
if (depth < 2) {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.')) {
|
||||||
|
found = found.concat(findProjects(join(root, e.name), depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function npmScripts(dir) {
|
||||||
|
try { return Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts ?? {}); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors.
|
||||||
|
function checksFor(p) {
|
||||||
|
switch (p.kind) {
|
||||||
|
case 'dotnet':
|
||||||
|
return [['dotnet', ['build', '-warnaserror']], ['dotnet', ['test', '--no-build']]];
|
||||||
|
case 'node': {
|
||||||
|
const s = npmScripts(p.dir);
|
||||||
|
return [
|
||||||
|
s.includes('lint') && ['npm', ['run', 'lint']],
|
||||||
|
s.includes('build') && ['npm', ['run', 'build']],
|
||||||
|
s.includes('test') && ['npm', ['test', '--if-present']],
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
case 'go':
|
||||||
|
return [['go', ['build', './...']], ['go', ['vet', './...']], ['go', ['test', './...']]];
|
||||||
|
case 'rust':
|
||||||
|
return [['cargo', ['clippy', '--all-targets', '--', '-D', 'warnings']], ['cargo', ['test']]];
|
||||||
|
case 'python':
|
||||||
|
return [['ruff', ['check', '.']], ['pytest', ['-q']]];
|
||||||
|
case 'cmake':
|
||||||
|
// cmake needs a configured build dir; without one there is nothing safe to drive.
|
||||||
|
return existsSync(join(p.dir, 'build', 'CMakeCache.txt'))
|
||||||
|
? [['cmake', ['--build', 'build']]]
|
||||||
|
: [];
|
||||||
|
case 'make':
|
||||||
|
return [['make']];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(bin, args, cwd) {
|
||||||
|
try {
|
||||||
|
execFileSync(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') return null; // toolchain absent: not a policy failure
|
||||||
|
// Strip ANSI: this text is handed to the model, and escape codes are noise to it.
|
||||||
|
const out = `${e.stdout ?? ''}${e.stderr ?? ''}`
|
||||||
|
.replace(/\[[0-9;]*m/g, '')
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
return out.slice(-40).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = isPush ? 'Push' : 'Tag';
|
||||||
|
const projects = findProjects(ROOT);
|
||||||
|
// Advisory findings: reported to the model without blocking the call.
|
||||||
|
const notes = [];
|
||||||
|
|
||||||
|
for (const p of projects) {
|
||||||
|
const rel = p.dir === ROOT ? '.' : p.dir.slice(ROOT.length + 1);
|
||||||
|
for (const [bin, args] of checksFor(p)) {
|
||||||
|
const fail = run(bin, args, p.dir);
|
||||||
|
if (fail !== null) {
|
||||||
|
deny(
|
||||||
|
`${action} BLOCKED: \`${bin} ${args.join(' ')}\` failed in ${rel} (${p.kind}).\n\n` +
|
||||||
|
`${fail}\n\n` +
|
||||||
|
`CLAUDE.md: build and test must pass — zero errors, zero warnings — before any push or tag. Fix, then retry.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets: assume every repo is public.
|
||||||
|
const leaks = run('gitleaks', ['dir', ROOT, '--no-banner', '--redact', '-v'], ROOT);
|
||||||
|
if (leaks !== null) {
|
||||||
|
deny(
|
||||||
|
`${action} BLOCKED: gitleaks found candidate secrets.\n\n${leaks}\n\n` +
|
||||||
|
'CLAUDE.md: never commit secrets, credentials, tokens, or .env files. Remove them, rotate anything exposed, then retry.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub Actions are a supply-chain surface; lint and audit them before they ship.
|
||||||
|
const wfDir = join(ROOT, '.github', 'workflows');
|
||||||
|
if (existsSync(wfDir)) {
|
||||||
|
// Workflow files are passed explicitly: actionlint otherwise demands a .git
|
||||||
|
// directory, which a non-colocated jj repo does not have.
|
||||||
|
let wfFiles = [];
|
||||||
|
try {
|
||||||
|
wfFiles = readdirSync(wfDir)
|
||||||
|
.filter((n) => /\.ya?ml$/.test(n))
|
||||||
|
.map((n) => join(wfDir, n));
|
||||||
|
} catch { wfFiles = []; }
|
||||||
|
|
||||||
|
if (wfFiles.length) {
|
||||||
|
const al = run('actionlint', wfFiles, ROOT);
|
||||||
|
if (al !== null) {
|
||||||
|
deny(`${action} BLOCKED: actionlint found workflow errors.\n\n${al}\n\nFix the workflow errors, then retry.`);
|
||||||
|
}
|
||||||
|
// Advisory, not blocking: zizmor's high-severity set includes unpinned-uses,
|
||||||
|
// which fires on ordinary `@v4` tags. Surfaced to the model to raise with Malcolm.
|
||||||
|
const zz = run('zizmor', ['--offline', '--min-severity', 'high', '--min-confidence', 'high', ...wfFiles], ROOT);
|
||||||
|
if (zz !== null) {
|
||||||
|
notes.push(
|
||||||
|
`zizmor found high-severity GitHub Actions findings (advisory — the ${action.toLowerCase()} was NOT blocked):\n\n${zz}\n\n` +
|
||||||
|
'Tell Malcolm what was found and ask whether to fix now, suppress with `# zizmor: ignore[rule-name]`, or leave it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
// Fail open, but loudly — silence here would look identical to "everything passed".
|
||||||
|
notes.push(
|
||||||
|
`bash-guard found no recognized project layout under ${ROOT}, so NO build or test ran before this ${action.toLowerCase()}. ` +
|
||||||
|
'Only the secret and workflow scans were applied. Verify the change some other way before treating it as validated.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// additionalContext without permissionDecision: the call proceeds through the normal
|
||||||
|
// permission flow, and the model still receives these findings.
|
||||||
|
if (notes.length) {
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
additionalContext: notes.join('\n\n---\n\n').slice(0, 9500),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/bash-guard.mjs\"",
|
||||||
|
"timeout": 600,
|
||||||
|
"statusMessage": "Checking jj/attribution policy; gating push on build + tests..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/home/mroberts/.local/bin/shush --changes-only --hook-output",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user