Skip to content

Latest commit

 

History

History
552 lines (496 loc) · 31.8 KB

File metadata and controls

552 lines (496 loc) · 31.8 KB

redential login, redential submit, redential logout, redential status

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 session

login: device authorization flow

Standard 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).

  1. POST {SITE_URL}/api/cli/device/authorize, empty body. Response: {device_code, user_code, verification_uri, expires_in, interval}.

  2. The CLI prints verification_uri and user_code, then makes a best-effort attempt to open verification_uri in your default browser (open on macOS, xdg-open on Linux, rundll32 url.dll,FileProtocolHandler on Windows — no shell string on any platform, no new dependency: this uses only node: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_uri is server-controlled, so it's treated as untrusted before being handed to a native opener: only http/https is ever opened (never file:// or an app-custom scheme), and the URL is always its own argv element, never interpolated into a shell command.

  3. The CLI polls POST {SITE_URL}/api/cli/device/token with {device_code} every interval seconds until:

    • {access_token}, HTTP 200 — success, stored locally (see below). The response MAY also include an account_label string (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_in seconds 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's authorization_pending/slow_down are 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 (see pollJson in src/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.

Where the token lives

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.

status: local state, read-only

redential status

A 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 different SITE_URL (told apart explicitly, same check submit itself makes), or not logged in at all. When the stored session has an account_label (above), the logged-in line names it — e.g. Logged in: yes (as jane@example-EXAMPLE.com, https://www.redential.com) — so status finally 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 prints access_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: review, then upload

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:

  1. Requires a stored session whose site_url matches the current SITE_URL (redential login first, otherwise it refuses).

  2. 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) submit refuses immediately, before any network call at all, before this repo's bundle JSON is even printed.

  3. Once the bundle is built, and before the consent box below, submit checks whether it looks weak — fewer than 10 authored commits, a zero-day span, or repo.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 --unshallow and 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):

    1. 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.
    2. A boxed human-readable consent summary, titled "WHAT GETS UPLOADED" (formatConsentSummary, src/summary.tssubmit's own surface for this; as of the phase-2 console-UX redesign scan no 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).
    3. The interactive private-label promptPrivate label for this repo (only you will ever see it): — fires HERE, but only when --label wasn'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.
    4. The private-label consent line
      Plus your private label: «X» (travels alongside the bundle, never
      inside it — only you will ever see it)
      
      — the label is never part of the bundle JSON below; this is the one place its exact value is shown before consent (principle 4).
    5. 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 UPLOADED because that box describes data sent to Redential.
    6. A header line —
      Exact payload (byte-for-byte what gets sent):
      
      — then the bundle JSON itself, byte for byte what the upload later sends. This closes the gap 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 submit output (scripted use) is unaffected — submit has no --json flag (that's scan-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.

  4. Asks "Upload this to your Redential profile? (Y/n)" — a separate confirmation from the unified pre-scan confirmation scan already 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 explicit y/yes or n/no. Owner directive (2026-08): the upload question, by contrast, DOES default to Y (Enter accepts) — it's the ONE merged decision both a direct redential submit and a TTY scan continuing into this same flow ever ask (the old separate scan-side "Add this to your Redential profile?" prompt is gone). --yes answers the authorization question (same meaning as scan --yes); --confirm-upload separately answers the upload question. Both are required flags for a fully non-interactive submit, on purpose — consenting to be scanned and consenting to upload are different decisions; --label is a third, separately required flag for that same fully non-interactive case (step 2).

  5. 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, submit refuses outright, before a single byte of the bundle or an identity lookup ever leaves the machine — this is submit-only behavior; scan still only ever warns, never blocks, since scan has no network access to make the real determination.

  6. 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.

  7. 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.

  8. POST {SITE_URL}/api/cli/bundles with Authorization: Bearer <access_token> and the printed bundle JSON as the body — plus, if step 7's corroboration check succeeded, an X-Redential-Identity-Corroboration header (below). On success: {id}. Only the id is 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's signed.ratio is 0, submit prints one neutral-toned stderr tip about signing future commits (owner follow-up, 2026-08 — relocated here from scan's own summary, which used to show it unconditionally before any upload happened; see scan.md).

  9. POST {SITE_URL}/api/cli/private-label with {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) and submit still exits 0, since the bundle itself is already safely uploaded (see exit-codes.md).

  10. Records the upload locally (last-submission.json, above) — not part of what's sent, just local bookkeeping for a later scan'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.

The remote-visibility gate (submit-only)

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/3xx response 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 than scan's own warn-only heuristic: on an inconclusive result, submit falls back to printing publicHostWarning's own (longer) message and proceeds.
  • A captive corporate proxy that answers 200 for every host will make this probe look public and block submit. That is a false block, not a leak — see corporate-networks.md.

npm release-date check (submit-only)

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-auth for auth/better-auth;
  • @lemonsqueezy/lemonsqueezy.js for payments/lemonsqueezy;
  • @paddle/paddle-js and @paddle/paddle-node-sdk for payments/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.

Identity corroboration (submit-only)

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/emails request is unreachable, times out, returns a non-2xx status (including a 429 rate-limit), or returns a body that doesn't match the expected shape, the CLI simply omits both the printed line and the header and submit proceeds exactly as it would without this feature.
  • The same omission happens in the degenerate case where total_claimed would 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.

Version check (login/submit only — never scan)

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 fetch sends, 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 login and submit, 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 (checkForUpdate swallows every error and is timeout-bounded; see the function's own contract in src/version-check.ts).
  • Where it does NOT run — ever: scan. Principle 1 states scan makes ZERO network calls, no exceptions, and that rule is inviolable regardless of how harmless a given call looks in isolation — the whole point of scan being 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, reading test/privacy/ zero-network.test.ts) without having to trust a judgment call about which outbound calls are "safe." checkForUpdate deliberately never references fetch/http/https directly — it goes through http-client.ts's getJson — so a plain grep for network APIs couldn't catch it being wired into scan's call graph by mistake. test/privacy/zero-network.test.ts encodes the actual rule instead: version-check.ts may only ever be imported by login.ts/ submit-command.ts; that test fails if it's ever imported from scan.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.

Error handling

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.