scan never touches the network (principle 1). These three commands are the
only place the CLI ever does — see principles.md.
redential login # device flow, one time
redential submit --repo <path> # interactive: prints the bundle, then asks
redential submit --author you@x.com --yes --confirm-upload --label "Acme Corp" # non-interactive (exit codes: docs/exit-codes.md)
redential logout # delete the stored sessionStandard OAuth 2.0 Device Authorization Grant (RFC 8628) shape — nothing
device-flow-specific existed in Redential's backend at the time this command
was written, so this doc is the contract the server implements against,
not a description of something that already existed. SITE_URL is public by
design (https://www.redential.com, overridable via REDENTIAL_SITE_URL
for local development/testing against a mock server).
-
POST {SITE_URL}/api/cli/device/authorize, empty body. Response:{device_code, user_code, verification_uri, expires_in, interval}. -
The CLI prints
verification_urianduser_code, then makes a best-effort attempt to openverification_uriin your default browser (openon macOS,xdg-openon Linux,rundll32 url.dll,FileProtocolHandleron Windows — no shell string on any platform, no new dependency: this uses onlynode:child_process). This reverses an earlier version of this doc, which said the CLI would never do this ("no surprise network/process spawn") — most CLIs with a device flow (gh auth login,vercel login, etc.) auto-open, and the printed URL/code were never removed as the fallback, so the tradeoff changed. Auto-open is never load-bearing: any failure (headless box, SSH session, unknown platform, no browser installed, the opener binary missing) is silently swallowed — login proceeds exactly as if auto-open didn't exist.verification_uriis server-controlled, so it's treated as untrusted before being handed to a native opener: onlyhttp/httpsis ever opened (neverfile://or an app-custom scheme), and the URL is always its own argv element, never interpolated into a shell command. -
The CLI polls
POST {SITE_URL}/api/cli/device/tokenwith{device_code}everyintervalseconds until:{access_token}, HTTP 200 — success, stored locally (see below). The response MAY also include anaccount_labelstring (a handle or masked email the server chooses to identify the account) — optional, and validated before it's ever persisted (see below).{error: "authorization_pending"}, HTTP 400 — keep polling.{error: "slow_down"}, HTTP 400 — keep polling, backing off by 5s.{error: "access_denied"}or{error: "expired_token"}, HTTP 400 — abort with a non-zero exit code.- Polling also aborts once
expires_inseconds have elapsed without a terminal response.
The endpoint uses HTTP 400 as part of its normal vocabulary — it's the status for every
{error: "..."}shape, not just the terminal ones — since RFC 8628'sauthorization_pending/slow_downare non-fatal states the client is expected to poll through. The CLI's HTTP layer must read the body on a 400 from this endpoint instead of treating it as a failed request (seepollJsoninsrc/http-client.ts, used only here — every other request in this doc treats non-2xx as a real failure). Any error value not listed above (e.g. a malformed request) is treated as an unexpected response and aborts, same as a truly unrecognized shape.
Nothing except the device code itself is ever sent during this flow.
credentials.json in the OS-appropriate per-user config directory
(config.ts's DEFAULT_CONFIG_DIR, derived purely from os.homedir(),
no env var reads, no dependency):
| Platform | Path |
|---|---|
| macOS / Linux | ~/.config/redential/credentials.json |
| Windows | %USERPROFILE%\AppData\Roaming\redential\credentials.json |
Same directory as the device salt (salt.ts), written with mode 0600.
Contents: {access_token, site_url, obtained_at, account_label?}.
site_url records which SITE_URL issued the token: submit refuses
(and asks you to log in again) if the CLI's current SITE_URL doesn't
match, so a REDENTIAL_SITE_URL change can never silently send a stored
token to a different host.
account_label (optional, issue #63). The device-token response
(above) MAY include an account_label string — a handle or masked email
the server chooses to identify the account, so status can later show
which account the stored session actually belongs to (a real footgun
otherwise: a machine logged in long ago, a second account or a company
workspace invitation created later, and submit quietly uploading to the
wrong identity). The response body is untrusted server input (pollJson
in src/http-client.ts blind-casts JSON to a type, it doesn't validate
it), so login.ts's sanitizeAccountLabel validates the value before it
is ever written to this file: it must be a string, is trimmed, and is
rejected — silently, never as a login error, since the field is optional
by design — if it's empty after trimming, longer than 64 characters, or
contains control characters (same bar as private-label.md's
validation, checked independently rather than sharing code with it, since
one validates something the user types and the other something the
server sends). A dropped label never fails or changes login in any other
way; the session is simply stored without it, identical to how an older
server that never sends the field behaves.
0600 on Windows. NTFS has no POSIX permission bits, so the mode: 0o600
passed to writeFileSync is a no-op there — it restricts nothing and
errors on nothing. What actually protects the token on Windows is NTFS ACL
inheritance: a file created under the user's own %USERPROFILE%\AppData
tree inherits that directory's ACL, which by default grants access only to
the owning account plus Administrators/SYSTEM — not to other local user
accounts. This is a different mechanism than POSIX mode bits, not a
weaker one for the single-user-machine threat model this CLI assumes, but
it's worth being precise about: it's an OS default, not something this
CLI configures or verifies itself.
logout deletes this file. It never touches the device salt (salt,
sibling file in the same directory) — the salt is device-local and
unrelated to your session. It also never touches last-submission.json
(below): losing your session doesn't change what was actually uploaded.
last-submission.json, same directory. Written by submit
immediately after a successful upload: {site_url, bundle_hash, submitted_at, repo_fingerprint} — a local, unsalted sha256 of the
uploaded bundle's content (see src/submission-record.ts's
bundleContentHash) plus the bundle's own repo.repo_fingerprint, never
the bundle itself. Its only purpose is letting a later scan's wrapped
summary tell "already uploaded, nothing new to submit" from "not
submitted yet" (see docs/scan.md), and
letting status (below) show a glance at what was last uploaded — it's
read-only bookkeeping, never sent anywhere, and unlike
credentials.json/salt it isn't written with restricted file
permissions, since it isn't a secret: just a hash of content you already
reviewed and already chose to upload. repo_fingerprint is optional in
the type — a record written by an older CLI version simply won't have it;
status shows "unknown" for that line rather than crashing or migrating
the file.
redential statusA snapshot of local CLI state only — zero network, works whether or not you're logged in:
- CLI version and the config dir path (from
DEFAULT_CONFIG_DIR, above). - Login state: logged in and to which
SITE_URL, a stored session for a differentSITE_URL(told apart explicitly, same checksubmititself makes), or not logged in at all. When the stored session has anaccount_label(above), the logged-in line names it — e.g.Logged in: yes (as jane@example-EXAMPLE.com, https://www.redential.com)— sostatusfinally answers "as whom", not just "yes/no" (issue #63). Sessions without one (older CLI versions, or a server that doesn't send the field) show exactly the same line as before this feature. Never printsaccess_token— same "never log the token" rule as every error path in this CLI. - The last submission on record (if any): timestamp, plus prefixes (12 hex characters) of the bundle hash and repo fingerprint — enough to eyeball "is this the record I think it is" without printing the full values into a terminal that might get pasted into a support thread.
src/status-command.ts reads only files this CLI itself already writes
(credentials.json, last-submission.json) — never the scanned repo,
never a git command, never the network.
submit builds the bundle through the exact same code path scan
uses (buildBundleInteractively, shared by both commands) — same author
selection, same unified pre-scan confirmation, same runScan. scan can
also hand off to this exact flow IN-PROCESS, right after printing its own
summary (see scan.md) — the two
entry points share every invariant below identically. It then:
-
Requires a stored session whose
site_urlmatches the currentSITE_URL(redential loginfirst, otherwise it refuses). -
Resolves the private label — see docs/private-label.md for the full design/discussion record. Mandatory on every submit:
--label <text>if given (validated immediately, before any network call, TTY or not); otherwise, on a real TTY, resolved by the interactive prompt inside step 3's sequence below; otherwise (non-TTY with no--label)submitrefuses immediately, before any network call at all, before this repo's bundle JSON is even printed. -
Once the bundle is built, and before the consent box below,
submitchecks whether it looks weak — fewer than 10 authored commits, a zero-day span, orrepo.shallow === true— and if so prints a non-blocking stderr notice (thinHistoryNotice,src/submit-command.ts) suggesting a rescan with more history (or, for a shallow clone specifically,git fetch --unshallowand a rescan). Never blocks the submit, never a new prompt, never changes the exit code. On a real TTY, output then happens in a fixed order, everything before the upload prompt (reordered, owner directive, 2026-08 — see the ZERO-NETWORK INVARIANT below):- A one-line short summary — span of history, commit count, and
detected-capability count, e.g. "2 years of private work · 1,378
commits · 23 capabilities detected"; when at least one detected skill
carries
evidence: "structural", a structural count is appended, e.g. "23 capabilities detected (1 structural)" — visible right at the upload edge, not buried inside the box or the JSON. - A boxed human-readable consent summary, titled "WHAT GETS
UPLOADED" (
formatConsentSummary,src/summary.ts—submit's own surface for this; as of the phase-2 console-UX redesignscanno longer prints a consent box of its own, see docs/scan.md) listing what IS uploaded (commit count and span, detected-skill count with the top 3 names, time patterns/languages/categories as aggregates, salted fingerprints — every number read off the bundle just printed, never hardcoded) and what is NEVER uploaded (source code, file names, commit messages, the repo's name, other contributors' identities). - The interactive private-label prompt —
Private label for this repo (only you will ever see it):— fires HERE, but only when--labelwasn't already given in step 2. An invalid answer (empty, too long, control characters, or secret-shaped) re-asks up to 2 times; the final failed attempt aborts the whole submit (exit 1, nothing uploaded) rather than asking forever. - The private-label consent line —
— the label is never part of the bundle JSON below; this is the one place its exact value is shown before consent (principle 4).
Plus your private label: «X» (travels alongside the bundle, never inside it — only you will ever see it) - If the bundle contains an audited npm release-check candidate, a
disclosure explains that confirmation will allow selected public
package names to be queried from npm, which receives those names and
normal connection data such as the user's IP address. It also states
that npm never receives source, the repository URL, the bundle, or the
Redential token. This disclosure sits outside
WHAT GETS UPLOADEDbecause that box describes data sent to Redential. - A header line —
— then the bundle JSON itself, byte for byte what the upload later sends. This closes the gap
Exact payload (byte-for-byte what gets sent):scan-only builds left open (see privacy-tests.md): the request body is the literal string that was printed, not a re-serialization of the parsed object. INVARIANT (owner directive, 2026-08): this JSON is ALWAYS the last thing printed before the single upload confirmation (step 4 in the main list below) — nothing may come between them, on any path.
ZERO-NETWORK INVARIANT (owner directive, 2026-08): everything in this numbered list is purely local — no identity-corroboration lookup, no visibility-gate probe, no bundle upload happens anywhere above the upload prompt. The "yes" answer to that one question IS the network consent for the entire flow; see step 4 in the main list below for what only starts after it. Declining means zero network calls happened anywhere in the run.
Piped/redirected
submitoutput (scripted use) is unaffected —submithas no--jsonflag (that'sscan-only) — the raw bundle JSON is still the very first line printed, byte-identical to every prior release; the short summary, consent box, label prompt/line, and payload header above are a TTY-only addition. - A one-line short summary — span of history, commit count, and
detected-capability count, e.g. "2 years of private work · 1,378
commits · 23 capabilities detected"; when at least one detected skill
carries
-
Asks "Upload this to your Redential profile? (Y/n)" — a separate confirmation from the unified pre-scan confirmation
scanalready requires (see scan.md): "Scan<N>commits by<email>? This confirms you're authorized to analyze this repository. (y/n)", no default at all — pressing Enter re-asks the same question until you give an explicity/yesorn/no. Owner directive (2026-08): the upload question, by contrast, DOES default to Y (Enter accepts) — it's the ONE merged decision both a directredential submitand a TTYscancontinuing into this same flow ever ask (the old separatescan-side "Add this to your Redential profile?" prompt is gone).--yesanswers the authorization question (same meaning asscan --yes);--confirm-uploadseparately answers the upload question. Both are required flags for a fully non-interactivesubmit, on purpose — consenting to be scanned and consenting to upload are different decisions;--labelis a third, separately required flag for that same fully non-interactive case (step 2). -
Runs the remote-visibility gate (below) — the FIRST network call of the whole run, reachable only after step 4's "yes". If it's confirmed public,
submitrefuses outright, before a single byte of the bundle or an identity lookup ever leaves the machine — this issubmit-only behavior;scanstill only ever warns, never blocks, sincescanhas no network access to make the real determination. -
For the small audited subset of npm-backed skills described below, checks the public npm package creation date. Every lookup completes or aborts before the upload starts. Findings are warnings only; lookup failures and incomplete data are silent and never block the upload.
-
Fetches identity corroboration (below) and, if it succeeds, prints one informational line with the result — see that section for exactly what is and isn't sent. Moved here, after the visibility gate (owner directive, 2026-08): corroboration is informational, not part of the reviewed payload, so it no longer justifies a network call before the upload question — it fires only once the user has already said yes. Never blocks or delays the next step: any failure here simply skips the line and sends no header.
-
POST {SITE_URL}/api/cli/bundleswithAuthorization: Bearer <access_token>and the printed bundle JSON as the body — plus, if step 7's corroboration check succeeded, anX-Redential-Identity-Corroborationheader (below). On success:{id}. Only theidis ever printed back — never the full response body, so a change on the server side can't accidentally start echoing sensitive content into the terminal. Right after this line, if the uploaded bundle'ssigned.ratiois 0,submitprints one neutral-toned stderr tip about signing future commits (owner follow-up, 2026-08 — relocated here fromscan's own summary, which used to show it unconditionally before any upload happened; see scan.md). -
POST {SITE_URL}/api/cli/private-labelwith{bundle_id: <the id from step 8>, private_label: <the label resolved in step 2>}— only after step 8 has already succeeded. See docs/private-label.md for the full contract and failure semantics: this request is never retried, and a failure here never triggers a second bundle upload — it only prints a warning (naming the label, so it can be set again from the web) andsubmitstill exits 0, since the bundle itself is already safely uploaded (see exit-codes.md). -
Records the upload locally (
last-submission.json, above) — not part of what's sent, just local bookkeeping for a laterscan's next-step hint. Unlike the version-check notice below, this is not best-effort: a failure here (e.g. an unwritable config dir) surfaces as a real error, since silently swallowing it would leave the CTA wrong. Never includes the private label — see docs/private-label.md.
isKnownPublicHost (src/public-remote.ts) is a local heuristic: it
recognizes github.com/gitlab.com/bitbucket.org-shaped remote URLs, but
"known host" isn't the same as "publicly accessible" and scan has zero
network access to tell the difference — the CLI's primary use case is a
private employer repo hosted on github.com. scan never blocks on it; it
only prints a one-line, non-blocking notice at the very end of its own
output (connectableRepoNotice — see
scan.md).
submit already makes network calls, so it can do better: an anonymous
HEAD request straight to the remote URL itself (never to
SITE_URL — the remote URL never travels to Redential's servers).
submit never prints scan's own end-of-output notice pre-emptively —
this gate is its real, definitive answer:
- Only fires for
isKnownPublicHost-shaped remotes; never probes an arbitrary self-hosted URL. - Never fires if the remote URL carries embedded credentials or a token query param — those are gated by definition and the check must never turn into an authenticated request the user didn't ask for.
- A confirmed
2xx/3xxresponse blocks submit, with a message suggesting the GitHub App instead (it reads the actual code and grants a stronger tier than a local metadata scan). - Anything else — a
4xx/401/404(private/gated), a network error, a timeout, or a URL that couldn't be converted to something probeable — does not block. Absence of proof isn't proof of privacy, but this check must never be flakier thanscan's own warn-only heuristic: on an inconclusive result,submitfalls back to printingpublicHostWarning's own (longer) message and proceeds. - A captive corporate proxy that answers
200for every host will make this probe look public and block submit. That is a false block, not a leak — see corporate-networks.md.
After upload confirmation and after the remote-visibility gate has allowed
submission, submit checks a deliberately small, audited subset of detected
skills against the public npm registry. It requests the official full
packument with:
GET https://registry.npmjs.org/{encoded-package-name}
Accept: application/json
A scoped package is encoded as one path segment (for example,
%40scope%2Fpackage). There is no query string, request body,
Authorization header, CLI-created Cookie, Redential header, or Redential
token. npm sees the selected public package name and ordinary connection
metadata, including the source IP. It never receives the bundle, repository
URL or remote, path, source, label, identity, or token.
The initial audited set is intentionally conservative:
better-authforauth/better-auth;@lemonsqueezy/lemonsqueezy.jsforpayments/lemonsqueezy;@paddle/paddle-jsand@paddle/paddle-node-sdkforpayments/paddle.
signatures/package-map.json stores only those eligible package keys. Their
slugs are still derived from the existing detection map; the list neither
detects skills nor records which package caused a detection. CI requires all
map keys for an eligible slug to be audited and excludes any slug reachable
through Tier 2. Consequently, ambiguous or cross-ecosystem slugs such as
ai/openai-api and auth/firebase-auth never trigger this check.
For each eligible detected slug, the CLI reads only time.created. If a slug
has several mapped npm references, all must return valid canonical UTC dates;
the earliest date is used. A warning appears only when the bundle's
first_seen is strictly earlier. Equality or a later date is not a finding.
Vendored code and private forks can legitimately predate a public npm release,
so the warning is non-accusatory, never adds a prompt, and explicitly says the
upload will continue. A known, low-risk limitation: if a package was ever
unpublished and republished on npm, time.created resets to the republish
date, so a legitimately old package can show a misleadingly recent date — an
acceptable gap given the check is already warn-only and never blocks the
upload.
The checker is bounded to four concurrent requests, 1.5 seconds per request, and 3 seconds for the entire operation. Every started request is awaited until it resolves or its abort timeout fires; npm work never continues in the background or overlaps the Redential upload. A 404, 429, 5xx, network failure, timeout, malformed response, missing/invalid date, incomplete multi-package result, exhausted deadline, or unexpected checker error silently omits the finding and allows upload. No result is cached: persisting it would create a local technology history and require separate TTL, permissions, corruption, and migration decisions.
TTY users see the npm disclosure after the private-label line and before the
payload header, still leaving the exact JSON immediately adjacent to the
upload question. Non-TTY stdout remains JSON-only with the bundle as its first
output; after --confirm-upload and a successful visibility gate, the same
disclosure is written to stderr immediately before npm is contacted. A decline,
a visibility refusal, or a bundle with no eligible skill produces no npm
request. --confirm-upload therefore authorizes both the reviewed upload and
these subsequent network steps.
The bundle schema and the byte-for-byte Redential upload body are unchanged. This new network destination was discussed in issue #81; no bundle field, Redential upload header, or schema change is involved.
After the single upload confirmation (step 4 above) has been answered
"yes", and after the remote-visibility gate (step 5) has already cleared,
submit makes one more authenticated request: GET {SITE_URL}/api/cli/identity/emails with Authorization: Bearer <access_token> and a 5s timeout. This is one of the few network calls the
CLI ever makes, and — like the remote-visibility gate — it only ever runs
inside submit, never scan, consistent with principle 1. Owner directive
(2026-08): this lookup used to fire BEFORE the upload confirmation — moved
here so that confirmation is a genuine zero-network gate: declining it
means zero network calls happened anywhere in the run, not "zero calls
except this one lookup."
The response, {emails: [...]}, is the account's verified emails: the
Redential account email plus the verified GitHub primary email, typically
one or two entries. This is deliberately a short list, not "all your
verified GitHub emails" — a developer's git history legitimately contains
noreply/old-work addresses that will never appear on it, which is
exactly why the absence of a match is treated as neutral, never negative
(see below).
Each returned email is hashed locally with the same device salt used for
the bundle's identity.author_identity_hashes (saltedHash, see
docs/scan.md's device-salt note) and compared
against those hashes. The comparison only ever produces two integers:
corroborated_count (how many bundle author-identity hashes matched) and
total_claimed (how many the bundle claims in total). Nothing else about
the match — which email, which hash — survives past this comparison.
Because these two counts leave the machine but never appear inside the
printed bundle itself, principle 4 ("no hidden fields, no enrichment
after review") still requires the user to see them before they're sent —
submit prints exactly one calm, informational line, right after
computing them and before the actual upload request that carries them:
- Full match:
N of N claimed identities match your account's verified emails. - Partial or zero match:
N of M claimed identities match your account's verified emails — unmatched ones simply won't earn the corroborated marker.
Neither phrasing is accusatory and neither blocks submit — an unmatched
identity is exactly as valid as before, just without an extra
corroboration marker server-side.
On upload, the two counts travel as a single optional HTTP header on the
POST /api/cli/bundles request (step 8 above):
X-Redential-Identity-Corroboration: {"corroborated_count": N, "total_claimed": M} (compact JSON). This is the only place they go — they
are never added to the bundle body, so the bundle stays byte-for-byte
identical to what was printed in step 3 and there is no schema change.
The header also plays no role in the server's duplicate-bundle detection:
re-submitting an otherwise-unchanged bundle still dedups the same way
regardless of what the header says.
Fail-open, by design — corroboration can never fail or delay a submit:
- If the
identity/emailsrequest is unreachable, times out, returns a non-2xx status (including a429rate-limit), or returns a body that doesn't match the expected shape, the CLI simply omits both the printed line and the header andsubmitproceeds exactly as it would without this feature. - The same omission happens in the degenerate case where
total_claimedwould exceed the server's documented bound (1000) — sending a header the server has already said it won't accept is worse than sending none.
Privacy boundary: the fetched email addresses exist in process memory
only for the duration of this comparison. They are never logged, never
written to disk (not even to --debug output), and never placed in the
bundle or in any request body — only the two integers above ever leave
the machine, and only as part of the upload request, never on their own.
This is pinned by a dedicated privacy test,
test/privacy/identity-corroboration.test.ts. Server-side, only the two
integers are ever stored — the flow is otherwise inbound (server tells
the CLI which emails are verified) rather than outbound.
After a successful login or a successful submit upload, the CLI makes
one best-effort, non-blocking GET to the public npm registry
(registry.npmjs.org/@redential%2Fcli/latest) and prints a one-line notice
if a newer version exists (src/version-check.ts). This is the only place
outside the device flow and the bundle upload that this CLI ever reaches
the network, so the boundary is worth stating precisely:
- What's sent: nothing about you, your machine, or the repository —
the request carries no query params, no headers beyond the defaults
fetchsends, and no body. It's indistinguishable from any anonymous visitor fetching a public npm package's metadata; it is a download (checking what exists), not an upload (reporting what you did), which is the distinction principle 2 ("Explicit... no telemetry") is actually drawing — the CLI never phones home with usage data, independent of this. - When it runs: only bolted onto
loginandsubmit, and only after each has already fully completed its own job — a failing or slow registry can never fail or delay the login/upload itself (checkForUpdateswallows every error and is timeout-bounded; see the function's own contract insrc/version-check.ts). - Where it does NOT run — ever:
scan. Principle 1 statesscanmakes ZERO network calls, no exceptions, and that rule is inviolable regardless of how harmless a given call looks in isolation — the whole point ofscanbeing network-free is that a user can point it at a repository under an NDA, an audit, an air-gapped machine, or just their own paranoia, and verify zero network access (strace, disabling their network interface, readingtest/privacy/ zero-network.test.ts) without having to trust a judgment call about which outbound calls are "safe."checkForUpdatedeliberately never referencesfetch/http/httpsdirectly — it goes throughhttp-client.ts'sgetJson— so a plain grep for network APIs couldn't catch it being wired intoscan's call graph by mistake.test/privacy/zero-network.test.tsencodes the actual rule instead:version-check.tsmay only ever be imported bylogin.ts/submit-command.ts; that test fails if it's ever imported fromscan.ts,scan-command.ts,build-bundle.ts, or anywhere else in scan's dependency graph, regardless of whether the import itself references a network API literally. This was reviewed explicitly as a sensitive-zone change before being merged.
Every command-level error is one of ScanError / AuthError /
SubmitError / NetworkError (src/errors.ts). NetworkError messages
are built from the request's host, HTTP status, and a closed failure-class
phrase taken from error.code (or HTTP 407) — never from response
headers, body, or error.message — so a failed request can never echo a
bearer token or bundle content into a printed error. Connect failures
that used to collapse into Could not reach <host>. now name
connection refused, could not verify TLS certificate (see
corporate-networks.md), or proxy required
when the code is one of those classes. EOF on any interactive
prompt (attestation, author selection, or submit's upload confirmation)
aborts with a non-zero exit code rather than hanging or silently
proceeding, consistent with scan's existing prompts.