feat(flair): tps flair set-hub/clear-hub/show/probe — team-level config (ops-wn6g)#284
Conversation
…ig (ops-wn6g)
Adds four config actions to the existing `tps flair` subcommand surface:
tps flair set-hub <url> [--auth-mode admin-pass-file --auth-path <path>] [--port <n>]
tps flair clear-hub
tps flair show [--json]
tps flair probe [--json]
Persists ~/.tps/flair.json (mode 0600, atomic-write via .tmp + rename) so
other TPS subcommands and future branch-init can learn the team's Flair hub
without scraping env vars. Layer 1 of the TPS-Flair-aware provisioning
architecture; ops-209a will build on this to auto-install Flair on branches
during `tps office join`.
## Hub-less mode is a first-class state
Per Nathan 2026-05-17: 'there might be no hub (each branch office might just
have its own flair - but not a great idea) so the fed sync wouldn't need to
be running.' The schema reflects this — `hub: null` means hub-less mode and
fed-sync is skipped. `clear-hub` is the verb for entering that state.
## Schema
```jsonc
{
"hub": "https://flair.heskew.harperfabric.cloud" | null,
"auth": { "mode": "admin-pass-file", "path": "~/.flair/admin-pass" } | null,
"localPort": 9926
}
```
The auth path is a filesystem location — surfaced in `show` output for
audit/redirect. The file it points at (the actual admin pass) is never
read by config actions; that's the federation layer's concern.
## Tests
- New: 8 unit tests covering round-trip, atomic-write tmp cleanup,
mode 0600 permissions, unknown-auth-mode tolerance, missing-localPort
default, fresh-tpsroot bootstrap, and the hub-null persistence
contract.
- End-to-end CLI verified: set-hub success/show/JSON projection/clear-hub/
bad URL rejection/missing arg error.
- Existing `tps flair` lifecycle actions (install/uninstall/start/stop/
restart/status/logs/health/sync) unchanged.
## Test plan
- [x] `bun test test/flair-config.test.ts` — 8 pass
- [x] `bun run build` — dist regenerated
- [x] End-to-end CLI verification with tmp TPS_ROOT
- [ ] K&S ensemble review
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tps-kern
left a comment
There was a problem hiding this comment.
Architecture review — PR #284
Verification points (per ops-wn6g spec)
-
Forward-compat unknown auth mode ✅ —
readFlairConfigFilegates onraw.auth.mode === "admin-pass-file"; any future/unknown mode defaults tonull. Test"tolerates auth field with unknown mode"covers this. -
Atomic write pattern ✅ —
.tmp+renameSyncmatches the cli#281 outbox fix pattern. Test verifies no stale.tmpfile remains. Mode 0600 confirmed in tests. -
Hub-null persisted as explicit JSON null ✅ —
JSON.stringify(config, null, 2)serialisesnullvalues as JSONnull, not omitted keys. Test explicitly assertstoHaveProperty("hub", null). -
clear-hub preserves localPort ✅ — Reads existing config, writes
{ hub: null, auth: null, localPort: existing.localPort }. File stays present (config-presence semantics correct).
Findings (architecture/data-flow)
1. URL scheme validation is too permissive (MEDIUM)
set-hub only validates new URL(opts.hub) — which accepts ftp://, file://, ws://, and any registered scheme. A user who passes file:///tmp/foo or ftp://evil.com gets a clean write. The probe command then constructs probes using whatever protocol was stored.
Fix: Restrict hub URLs to https:// and http:// only (possibly http://localhost). A one-line allowlist check after new URL() succeeds.
if (!["https:", "http:"].includes(new URL(opts.hub).protocol)) {
console.error(`Unsupported protocol: ${new URL(opts.hub).protocol}. Use https:// or http://`);
process.exit(1);
}2. Trimmed URL validated but untrimmed written (LOW)
set-hub validates opts.hub.trim() for the empty-string and URL checks, but writes opts.hub (untrimmed) to the config file. If a user passes a URL with leading/trailing whitespace, validation passes but whitespace is persisted.
Fix: Normalise: opts.hub = opts.hub.trim() before validation and write.
3. probe could SSRF internal hosts if an attacker controls the config file (LOW, defense-in-depth)
If someone can write ~/.tps/flair.json (requires 0600 file access, so they're already on the box), they could set the hub URL to http://169.254.169.254/latest/meta-data/ and probe would hit the metadata endpoint. This is a low-severity concern since the attacker already has filesystem access, but the 5-second timeout + fetch to an arbitrary URL from config is a vector.
Mitigation already present: The URL must survive new URL() — so file:///etc/passwd works, but the probe only constructs ${protocol}//${host}/Health/0, which for file:// would be file:///etc/Health/0 (confusing but not the passwd file). Still, worth noting for audit trail. Combined with finding #1's fix, this is adequately mitigated.
Downstream ops-209a (branch-init) compatibility
branch-init consuming this config via readFlairConfigFile() is safe:
- Missing file → returns
{ hub: null, auth: null, localPort: 9926 }— hub-less mode is valid. - Missing
localPortin file → defaults to 9926. - Unknown
auth.mode→ defaults tonull(safe forward-compat). hub: null→ explicit JSON null, downstream check isif (config.hub).- Schema additions →
readFlairConfigFilecherry-picks known keys only; new keys are silently ignored.
No breakage expected. The contract is stable.
Other observations
- Test quality: 8 tests cover defaults, round-trip, atomic write, permissions, forward-compat auth, missing
localPort, directory creation, and null persistence. Good coverage. - Error messages:
set-hubproperly distinguishes empty URL from invalid URL.clear-hubgracefully handles no-config-exists. probeAbortSignal.timeout uses 2s local / 5s hub — reasonable. Node.jsfetch+AbortSignal.timeoutis correct.- Code clarity:
HarperOptsinterface extended cleanly with optional config fields.readFlairConfigFile/writeFlairConfigFileexported for reuse by other commands. - Minor:
readFlairConfigFileusesexistsSync/readFileSync/mkdirSync— consistent with the existing file's sync style. Not a concern for a CLI entrypoint.
APPROVE_WITH_NITS
tps-sherlock
left a comment
There was a problem hiding this comment.
Security review — PR #284
Findings (security/threat surface)
1. ✅ Mode 0600 enforcement — VERIFIED
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });Atomic .tmp + rename pattern is correct; test asserts st.mode & 0o777 === 0o600. No race window where a partial file is world-readable.
2. 🟡 URL validation accepts embedded credentials — CREDENTIAL LEAK RISK
try {
new URL(opts.hub);
} catch {
console.error(`Invalid URL: ${opts.hub}`);
process.exit(1);
}new URL("https://admin:secret@flair.example.com") parses successfully. The URL is stored as-is in flair.json (which is 0600, so disk exposure is limited to the owner), but tps flair show echoes it unredacted to stdout:
console.log(`Hub: ${config.hub ?? "(none — hub-less mode)"}`);This leaks credentials to terminal scrollback, shell history (if piped), and screen captures. The probe action correctly strips credentials for the HTTP request:
const probeUrl = `${u.protocol}//${u.host}/Health/0`;…but the persistence layer does not. Recommendation: either (a) reject URLs with .username or .password in set-hub, or (b) redact them in show output.
3. 🟡 --auth-path has no ownership/existence validation
set-hub stores authPath verbatim with no check that the caller owns the path or that the file exists:
auth:
opts.authMode === "admin-pass-file" && opts.authPath
? { mode: "admin-pass-file", path: opts.authPath }
: existing.auth,A user could set --auth-path /etc/shadow or another user's file. The file is never read by these config actions (per spec, that's the federation layer's concern), and show surfacing the path provides audit visibility. However, a misconfigured path could lead to a later silent failure when the federation layer tries to read it. Not blocking for this PR, but worth documenting as a known gap.
4. Minor — localPort is not validated
localPort: typeof opts.port === "number" ? opts.port : existing.localPort,No bounds check; negative values, zero, or values > 65535 are accepted. probe will simply fail to connect. Non-security, but consider adding a Number.isInteger(port) && port > 0 && port <= 65535 guard.
Verdict
APPROVE_WITH_NITS
Core security posture is solid: atomic writes, restricted permissions, no shell injection, and the probe correctly strips URL credentials before fetching. The embedded-credential URL acceptance is the only material concern — recommend addressing in a follow-up before this feature ships to a broader audience.
Two nits from Kern's APPROVE_WITH_NITS on PR #284: 1. **Protocol allowlist (MEDIUM)**: set-hub accepted file://, ftp://, ws:// and any registered URL scheme. A typo could persist a non-HTTP URL that the probe action then tries to fetch with unexpected semantics. Now restricts to https:// and http:// only. Verified rejection of ftp: and file:. 2. **Trim consistency (LOW)**: set-hub validated trimmed input but wrote the untrimmed value. Now trims once, validates the trimmed form, and persists the trimmed form. Verified with a URL containing leading + trailing whitespace — input trimmed before validation + persist. One new test (round-trip persistence of trimmed URL). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ow, bound port Three nits from Sherlock's APPROVE_WITH_NITS on PR #284: **1. Embedded-credential URL leak (MEDIUM)** `new URL("https://admin:secret@flair.example.com")` parses successfully. set-hub stored the URL as-is in flair.json (mode 0600 limits disk exposure to owner) but `tps flair show` echoed it unredacted to stdout — leaking creds to terminal scrollback, shell history if piped, and screen captures. Two-part fix: - **Reject at write time**: set-hub now refuses URLs with .username or .password set. Credentials belong in --auth-mode/--auth-path, not embedded in the URL. Refuses both `user:pass@host` and `user@host`. - **Redact at read time** (defense-in-depth): new `redactUrlCredentials` helper strips userinfo before `show` (both plain and --json) renders. Catches already-stored configs from pre-fix versions or hand-edited flair.json. **2. localPort bounds check** `set-hub --port -1` or `--port 99999` was accepted. Now requires Number.isInteger + 1..65535. **3. (Non-blocking) --auth-path ownership/existence not validated** Sherlock flagged as known gap. Federation layer reads the file at fed-sync time; misconfigured paths surface there. Documented as known limit; not addressed in this PR. Four new tests for redactUrlCredentials (user:pass, user-only, no-creds, non-URL). All 13 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tps-kern
left a comment
There was a problem hiding this comment.
Architecture re-review — both nit batches applied correctly.
Kern nits (r1) — verified
- Protocol allowlist:
set-hubnow rejectsftp://,file://,ws://, etc. — onlyhttps://andhttp://accepted. ✅ - URL trim:
opts.hub.trim()is now the canonical value — validated, persisted, and displayed. Test confirms no trailing whitespace at rest. ✅
Sherlock nits — verified
- Embedded credentials rejected at write time:
set-hubchecksparsed.username || parsed.passwordand refuses. ✅ showredacts credentials:redactUrlCredentials()stripsuser:pass@from both JSON and human output. Defense-in-depth covers hand-edited or pre-fix configs. ✅- Port bounds:
1..65535integer check on--port. ✅
redactUrlCredentials edge cases all behave correctly — strips user:pass@host/path, strips username-only (trailing slash added by URL.toString() is benign), passes credential-free URLs through, returns non-URLs unchanged.
All 4 original spec checks still hold. Downstream ops-209a remains safe.
APPROVED.
tps-sherlock
left a comment
There was a problem hiding this comment.
Security re-review — PR #284 (r2)
All nits from r1 addressed:
1. Embedded credentials rejected at write time ✅
if (parsed.username || parsed.password) {
console.error(
"URL contains embedded credentials. Use --auth-mode + --auth-path instead; ...",
);
process.exit(1);
}2. Defense-in-depth redaction in show ✅
const hubDisplay = config.hub ? redactUrlCredentials(config.hub) : null;redactUrlCredentials strips user:pass@ from stored URLs (forward-compat for pre-existing/hand-edited configs). Tests cover cred-bearing, username-only, clean URL, and non-URL inputs.
3. Port bounds validated ✅
if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) { ... }4. URL trimmed before validation and write ✅
const trimmed = opts.hub.trim();Written value is trimmed, not raw opts.hub.
5. Protocol allowlist ✅
if (!["https:", "http:"].includes(parsed.protocol)) { ... }Remaining (non-blocking): --auth-path ownership/existence gap is documented as federation-layer concern. No new security surfaces introduced.
APPROVED
CI's Semgrep SAST flagged a comment containing 'ws://' as an 'Insecure WebSocket Detected' finding, even though it's inside a // line comment that explains why we reject non-HTTP schemes. Reword to 'websocket' so the regex doesn't trip.
tps-kern
left a comment
There was a problem hiding this comment.
Re-approved — comment-only fix for semgrep false-positive on 'ws://' literal.
tps-sherlock
left a comment
There was a problem hiding this comment.
Re-approved — comment-only fix for semgrep false-positive on 'ws://' literal. No security impact.
Summary
Layer 1 of the TPS-Flair-aware provisioning architecture. Adds four config actions to the existing
tps flairsubcommand surface so other TPS code (and a future branch-init flow) can learn the team's Flair hub URL without scraping env vars.Persists
~/.tps/flair.json(mode 0600, atomic-write via.tmp+ rename). Existingtps flairlifecycle actions (install/uninstall/start/stop/restart/status/logs/health/sync) are unchanged.Hub-less mode is first-class
Per Nathan 2026-05-17: "there might be no hub (each branch office might just have its own flair - but not a great idea) so the fed sync wouldn't need to be running." The schema reflects this —
hub: nullmeans hub-less mode and fed-sync is skipped.clear-hubis the verb for entering that state.Why now
Today's Flair endpoint config is purely
FLAIR_URLenv var (default127.0.0.1:9926) + each agent's launcher hardcoding it. There's no shared registry of the team hub. Branches today don't know where to federate. This bead (ops-wn6g) is the foundation for ops-209a (auto-install Flair on branch + federate duringtps office join).Same-GH-org argument: anyone using
tpsdev-ai/cliis the same team usingtpsdev-ai/flair. TPS should be substrate-aware of its sibling.Tests
8 new unit tests in
test/flair-config.test.ts:.tmpfiles post-success)~/.tps/bootstrapPlus end-to-end CLI verification (in commit message): set-hub success, JSON projection, clear-hub, bad URL rejection, missing-arg error.
Test plan
bun test test/flair-config.test.ts— 8 passbun run build— dist regeneratedFollow-ups (separate beads, in dependency order)
tps office join --tunnel-viaSSH tunnel + launchd auto-provisiontps office join(depends on this PR + ops-7x9y)🤖 Generated with Claude Code