Skip to content

fix: redact session credentials from user --json --full output - #78

Merged
lmjabreu merged 5 commits into
mainfrom
lmjabreu/redact-user-token
Oct 14, 2026
Merged

lmjabreu merged 5 commits into
mainfrom
lmjabreu/redact-user-token

Conversation

@lmjabreu

@lmjabreu lmjabreu commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Overview

tdc user --json --full printed every field the API returns for the session user, including the SDK's token and cometChannel. The backend only includes session credentials when the bearer is a session-type token, so COMMS_API_TOKEN=<session token> tdc user --json --full would print a live credential to stdout.

The fix is in the shared formatter (filterEntityFields in src/lib/output.ts): for a top-level user entity, token and cometChannel are omitted on the curated and --full paths. formatJson, formatNdjson and the paginated formatters all go through it, so the status commands in #77 (which call formatJson(user, 'user', true)) are covered once it lands. #77 is not merged, so I did not test them here. Redaction is top-level only: a user nested inside another object is not redacted, and nothing prints one today.

Omit vs placeholder: omitted. A "[redacted]" string would look like a real value to scripts, and absence is what an OAuth login already produces.

Fields: token is the session credential. cometChannel is a judgement call: it is the user's realtime subscription channel, paired with cometServer, so I treated it as secret, but nothing in the repo or SDK confirms that. cometServer and clientId stay; worth a check with the backend owner that neither is sensitive.

Reference

Follow-up to #77. SDK: UserSchema in @doist/comms-sdk.

Test plan

  1. On main, with a session user containing token/cometChannel, run tdc user --json --full; the credentials are printed.
  2. On this branch, repeat:
    • tdc user --json --full has no token or cometChannel, other full fields (lang, shortName) remain
    • Curated tdc user --json output is unchanged

The users --json/--ndjson --full tests are defence in depth: the SDK already strips these fields from workspace users, so getSessionUser is the only real carrier.

Not verified: what the real backend returns for a session token. This rests on the _include_session_credentials behaviour described in the task and the SDK schema.

Adversarial review

Reviewer Head Raised Fixed Refuted Deferred Reviewer tokens
Fable (2 passes: conventions, bounds) 0cf3dee 7 5 1 1 362,526

Doistbot: 3 rounds, no P1 or P2, 7 P3 notes in total (4 fixed, 3 refuted, one of them raised twice). Last review was on cd3c931; e3b5d2a only changes a return type.

🤖 Generated with Claude Code

lmjabreu and others added 2 commits October 14, 2026 02:09
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@lmjabreu

Copy link
Copy Markdown
Contributor Author

@doistbot /review

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR redacts session credentials (token and cometChannel) from tdc user --json / --ndjson output, including --full and paginated paths, by applying an omitSecrets filter for top-level user entities in the shared formatter (filterEntityFields in src/lib/output.ts). Since formatJson, formatNdjson, and the paginated formatters all route through it, both the user and users commands are covered, curated output is preserved, and the filter is a no-op for non-user entities. All formatJson/formatNdjson call sites that render session-user data pass type: 'user', so the intended leak path is closed, and the documented shallow-redaction limitation has no concrete leak path today. No inline issues were flagged — the remaining open items worth confirming before merge are the backend sensitivity of cometServer/clientId and the follow-up verification of the #77 status commands once that PR lands.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (2)
  • P3 src/lib/output.ts:156: For every entity type except user, SECRET_FIELDS[type] is undefined, so this map only allocates a new array of references with no filtering benefit. --full list/paginated outputs (threads, channels, conversations, groups, workspaces) previously passed the original array straight through. Short-circuit before mapping when the type has no secrets, e.g. const secrets = SECRET_FIELDS[type]; if (!secrets) return data, and pass secrets into omitSecrets to avoid the repeated lookup per item.
  • P3 src/lib/output.test.ts:150: This matrix asserts only that the SECRET- values don't appear in the output, not that the token/cometChannel keys are omitted. Since the PR deliberately omits these fields instead of emitting a "[redacted]" placeholder, a future switch to placeholders would still pass every case here (and the users --full command test). Parse the single-object outputs (for formatPaginatedJson, check .results[0]) or inspect filterEntityFields directly, and assert not.toHaveProperty('token') / not.toHaveProperty('cometChannel') to pin the omission contract.

Share Feedback • Review Logs

… omission in tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@lmjabreu

Copy link
Copy Markdown
Contributor Author

@doistbot /review

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR routes all full JSON/NDJSON output for the top-level user entity through a new secret-omission filter in filterEntityFields, closing a credential leak where tdc user --json --full printed the session token and cometChannel. The redaction covers the curated, full, paginated, and NDJSON paths (all funnel through the shared formatter), curated output is preserved, and tests plus help/skill docs match the change. No inline issues were flagged — the implementation follows the existing per-entity filtering pattern, the omit-over-placeholder choice is sound, and the open question about cometChannel/clientId/cometServer sensitivity is already surfaced in the PR description for backend confirmation.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (2)
  • P3 src/commands/user.test.ts:197: This block is low-value, synthetic coverage. The PR notes the SDK already strips token/cometChannel from workspace users, so getWorkspaceUsers never returns the mocked leaky shape — the real carrier (getSessionUser) is already covered by the user --full test above. It also never asserts a full-only field survives, so it would still pass if --full were ignored and curated output were returned (curated output omits these fields too). The array redaction is already pinned in src/lib/output.test.ts. Suggest deleting this block; if the listUsers wiring is the concern, keep a single --json case that asserts a full-only field and that the parsed result has no token/cometChannel keys.
  • P3 src/lib/output.test.ts:166: This asserts reference identity (toBe), which pins the internal short-circuit optimization rather than behavior. If filterEntityFields ever returned an equal shallow copy for types without secrets — no observable change — this test would fail. toEqual(channels) (or just checking the items are unmodified) tests the same contract without coupling to the no-copy detail.

Share Feedback • Review Logs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@lmjabreu

Copy link
Copy Markdown
Contributor Author

@doistbot /review

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR closes a real credential leak by omitting the session token and cometChannel fields from top-level user JSON/NDJSON output, centralized in filterEntityFields so the curated, --full, and paginated paths are all covered while other entity types and untyped output are untouched. No inline issues were flagged. The omit-over-placeholder choice and the shared-helper approach fit the existing filtering pattern well; the follow-ups in the description (confirming cometServer/clientId sensitivity with the backend owner, and re-verifying the #77 status commands once that PR merges) are good items to track.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (3)
  • P3 src/lib/output.ts:121: omitSecrets returns T, but the object it returns has had token/cometChannel deleted (return copy as T). The signature hides the deletion, so the helper's result is typed as a complete T even though it is missing fields. Return Partial<T> (or a precise Omit keyed on the user secret fields) so the redaction is reflected in the type.
  • P3 src/lib/output.test.ts:166: This asserts reference identity (toBe), which pins the no-copy short-circuit rather than the behavior the test name describes (fields unmodified). A behaviorally equivalent filterEntityFields that returned a shallow copy for types without secrets would fail here. Use toEqual(channels) to keep the test on the contract.
  • P3 src/commands/user.test.ts:216: This block is synthetic and largely duplicates the src/lib/output.test.ts matrix. Per the PR description, the SDK already strips token/cometChannel from workspace users, so getWorkspaceUsers never returns the mocked leaky shape and this credential scenario can't occur through listUsers; the real carrier (getSessionUser) is covered by the user --json --full test above. The only unique behavior here is listUsers passing --full through to the formatter. Collapse it to a single --json case asserting lang is present and the secret keys are absent, or delete the block.

Share Feedback • Review Logs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@lmjabreu
lmjabreu marked this pull request as ready for review October 14, 2026 16:25
@lmjabreu
lmjabreu requested review from a team and pawelgrimm and removed request for a team October 14, 2026 16:25
@lmjabreu
lmjabreu merged commit e768abf into main Oct 14, 2026
8 checks passed
@lmjabreu
lmjabreu deleted the lmjabreu/redact-user-token branch October 14, 2026 22:12
doist-release-bot Bot added a commit that referenced this pull request Oct 14, 2026
## [3.5.2](v3.5.1...v3.5.2) (2026-10-14)

### Bug Fixes

* redact session credentials from user --json --full output ([#78](#78)) ([e768abf](e768abf))
lmjabreu added a commit that referenced this pull request Oct 14, 2026
The status commands print the user through formatJson, which since #78
drops token and cometChannel. This guards the call site.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
lmjabreu added a commit that referenced this pull request Oct 14, 2026
* feat(status): add tdc status show, set and clear

Needs a @doist/comms-sdk release that includes users.setStatus (Doist/comms-sdk-typescript#100);
the dependency bump is not in this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* build(deps): bump @doist/comms-sdk to 3.4.0

Brings in users.setStatus and users.clearStatus.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(skill): sync SKILL.md with tdc status

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: drop the tdc away lines from the README

The command no longer exists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(status): reject empty expiry values and read date-only --until as local

Addresses doistbot review: validate --until/--for presence instead of
truthiness, skip markdown preload for status, add --full to set/clear,
reuse the dates.ts unit constants, and cover the user Status line, the
set text output and fake-timer cleanup in tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(status): validate set input, register the user:write scope, report what the server returned

From an offline review of the whole PR: reject impossible dates, past
expiries, out-of-range messages and empty emoji before the dry-run branch,
require user:write through the scope table, print the server's status after
set and clear, add --full to show, and fix the stale AGENTS.md pointer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(status): reject an option-like value for --emoji

Commander reads the next token as the value, so --emoji --dry-run ran the
real write. An emoji never starts with a dash.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(status): accept fractional seconds, reject DST gaps, exit 1 when the server disagrees

Addresses the second doistbot round: parseLocalDate takes what
Date.toISOString() produces, a nonexistent local time in a daylight-saving
gap is rejected, clear and set exit 1 whenever the server's status does not
match the request (JSON output included), the JSON rendering lives in one
helper, and the read-only routing is asserted for both status methods.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(status): accept a date whose midnight is skipped, check the year and offset range, fail through CliError

From a revision review of 761dc90: the DST-gap rule rejected a date-only
--until on the one day a year when a zone's clocks skip midnight, years 0 to
99 round-tripped to 1900 to 1999, and offsets up to +23:59 were accepted.
set and clear now throw INVALID_STATE when the server disagrees, so --json
prints the usual stderr envelope and no process.exitCode state leaks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(status): word the past-expiry hint for datetimes, share formatStatus, cover the curated user JSON

From the third doistbot round: the midnight hint now appears only for a date
with no time, formatStatus moves to src/lib so tdc user no longer imports
from the status folder, and a test pins status in the curated tdc user --json
output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(status): say pause-notifications is Comms-only for now; explain the no-op error

From Scott's review: the help, README and skill text now say the pause holds
back Comms notifications only, that each app has to read the status, and that
held notifications are not sent later. An agent is told to pass the flag only
when asked. The set error now says nothing changed, the usual cause, and what
to run next.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(status): pin that --json --full redacts session credentials

The status commands print the user through formatJson, which since #78
drops token and cometChannel. This guards the call site.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
doist-release-bot Bot added a commit that referenced this pull request Oct 14, 2026
## [3.6.0](v3.5.2...v3.6.0) (2026-10-14)

### Features

* **status:** add tdc status show, set and clear ([#77](#77)) ([bdfcb16](bdfcb16)), closes [Doist/comms-sdk-typescript#100](Doist/comms-sdk-typescript#100) [#78](#78)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants