From f98cae1772298ca62cda2c502c10b3126a7ce6d9 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Sun, 26 Jul 2026 15:54:04 -0400 Subject: [PATCH] release: v2.61.0 - hub credentials search + settings drawer + context menu, credential vault guide Bundles the Windows hook fix prepared as 2.60.1, which was never tagged or published. Hub Credentials UI/UX The tab shipped in v2.59.0 as a flat list with no way to find anything: 40+ projects behind accordions, and one overloaded row per entry packing eleven badges plus five unlabelled icon buttons - where the eye icon toggled agent_readable (the flag that lets the agent pull plaintext into its transcript) one pixel from edit. - Cross-project search above the sub-tabs, / or Ctrl-K to focus. Free tokens AND-match name/description/env var/owner; project: scope: type: storage: name: env: inject: agent: shadow: qualifiers narrow further. Results group by credential name, so "where is STRIPE_KEY defined" is one glance across the global vault and every project. - Per-credential settings drawer: General, Exposure (both switches with blast radius), Secret (on-demand fingerprint + write-only Replace), Usage & relationships, separated Danger zone. - Right-click context menu, plus a kebab button and Shift-F10. The bare eye/zap icons are gone from rows. - Typed confirmations replace window.confirm; deleting or raising an exposure flag requires typing the credential name. - Projects sub-tab is a single-open accordion, so N expanded projects no longer means N independent fetchers going stale. Fixed: T.ok is not a theme token and never was - five call sites across hub_credentials.js and ui/components/credentials.js rendered color: undefined / background: "undefined15". Fixed: Escape closed the drawer AND wiped the search query behind it; it now closes the topmost layer only. The write-only wire is unchanged: no route returns a value, and search indexes metadata only. Docs - New cli/guide/credentials.html: storage internals, scope/override model and realm-atomic resolution, the three injection paths, what each exposure flag costs, Hub UI tour, search qualifier table, c3 creds CLI, audit trail, troubleshooting. - Wired into the guide nav across all pages, the home sidebar and a nav card, the c3_credentials tool card, and a new Credentials section in Getting Started. - Getting Started now warns that upgrading does not repair existing projects' hook wiring - c3 init must be re-run per project. - README Hub Credentials section rewritten for v2.61.0. - Fixed a pre-existing broken guide link (tools.html#memory -> #c3_memory). Windows hooks (prepared as 2.60.1) Every hook C3 installed on Windows was dead on arrival. Commands were written as cmd.exe /c '' '' posttool; Claude Code runs hooks under Git Bash, and MSYS argument conversion rewrites a standalone /c into C:/ before cmd.exe executes. cmd.exe launched with no switch, opened an interactive session, and read the hook's stdin JSON as console commands - so the hook never ran, and a > token in the payload became a shell redirect creating empty junk files in the repo root. Hook commands are now emitted as double-quoted forward-slash paths, which both bash and cmd parse correctly. Regression guard in tests/test_install_mcp_entrypoint.py. 1404 tests pass, ruff clean, esbuild clean on the concatenated hub bundle. Claude-Session: https://claude.ai/code/session_019nzX4rVE2CBcuT9AwnXmLZ --- CHANGELOG.md | 103 ++ CLAUDE.md | 142 +-- README.md | 30 +- cli/c3.py | 35 +- cli/guide/bitbucket.html | 1 + cli/guide/credentials.html | 554 +++++++++ cli/guide/getting-started.html | 37 + cli/guide/index.html | 11 +- cli/guide/oracle.html | 1 + cli/guide/tools.html | 5 +- cli/guide/workflow.html | 1 + cli/hub_ui/components/hub_credentials.js | 1336 +++++++++++++++++++--- cli/ui/components/credentials.js | 6 +- pyproject.toml | 2 +- tests/test_install_mcp_entrypoint.py | 34 + 15 files changed, 1985 insertions(+), 313 deletions(-) create mode 100644 cli/guide/credentials.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 5adfe85..eb10be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,109 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.61.0] - 2026-07-26 + +This release also carries the Windows hook fix prepared as 2.60.1, which was +never tagged or published — see its section below. + +### Added — Credential vault guide (`/guide/credentials.html`) + +The vault had shipped across three releases (v2.58.0 store, v2.59.0 Hub tab, +v2.61.0 search) with documentation spread over a tool card and a README +section. It now has a dedicated guide page, alongside the Bitbucket and +Oracle ones: storage internals and where each byte lives, the scope/override +model and why resolution is realm-atomic, the three injection paths, what +each exposure flag actually costs you, a Hub UI tour, the full search +qualifier table, the `c3 creds` CLI, the audit trail, and troubleshooting. +Linked from the guide nav, the home page, the `c3_credentials` tool card, and +a new Credentials section in Getting Started. + +### Changed — Hub Credentials: cross-project search, a per-credential settings drawer, and a context menu + +The Credentials tab shipped in v2.59.0 as a flat list with no way to find +anything: 40+ projects behind accordions, and one overloaded row per entry +packing eleven badges plus five unlabelled icon buttons — where the `eye` +icon toggled `agent_readable` (the flag that lets the agent pull plaintext +into its transcript) one pixel from `edit`. The write-only wire is unchanged: +no route returns a value, and search indexes metadata only. + +- **Cross-project search** — one field above the sub-tabs, `/` or `Ctrl/⌘-K` + to focus. Free tokens AND-match name / description / env var / owner; + `key:value` qualifiers narrow further (`project:` `scope:` `type:` + `storage:` `name:` `env:` `inject:` `agent:` `shadow:`). Results are + **grouped by credential name**, so "where is `STRIPE_KEY` defined" is one + glance across the global vault and every project instead of forty manual + expansions. Scope chips (All / Global / Project) and sorts by name, most + defined, last used, most used, or exposure. `↑↓` walks results, `↵` opens. +- **Per-credential settings drawer** — the single surface for editing one + entry: General (description, type, env var), Exposure (both switches with + the blast radius spelled out), Secret (on-demand resolution check + + fingerprint, and a write-only Replace field that starts empty and is + cleared the instant the request settles), Usage & relationships (created / + updated / last used / use count / storage, plus which projects override + this name or are overridden by it), and a separated Danger zone. +- **Right-click context menu** — on any row, plus a `⋯` button and + `Shift-F10` / the Menu key for keyboard users. Open settings, check + resolution, replace secret, toggle either exposure flag, copy name / + env var / fingerprint, open the project drill, delete. Rows are focusable + and open the drawer on `↵`; the bare `eye` and `zap` icons are gone. +- **Typed confirmations replace `window.confirm`** — deleting or raising an + exposure flag now shows what actually happens (which projects are affected, + that transcripts are searchable, that C3 keeps no copy of the value) and + deleting requires typing the credential name. +- **One manager mounted at a time** — the Projects sub-tab is a single-open + accordion with its own filter, so N expanded projects no longer means N + independent fetchers going stale against each other. Rows surface + agent-readable and overriding-global counts before you expand. +- **Fixed** — `T.ok` is not a theme token and never has been. Five call sites + across `hub_credentials.js` and `ui/components/credentials.js` rendered + project-scope badges, the resolvable-fingerprint marker, and a success + banner with `color: undefined` and `background: "undefined15"`. +- **Fixed** — `Escape` closed the drawer *and* wiped the search query behind + it; it now closes the topmost layer only. + +## [2.60.1] - 2026-07-26 + +### Fixed — Windows hooks never ran: the `cmd.exe` wrapper ate its own switch + +Every hook C3 installed on Windows was dead on arrival, silently. `c3 init` +wrote commands of the form `cmd.exe /c '' '' posttool`. +Claude Code runs hooks under Git Bash, and MSYS argument conversion rewrites a +standalone `/c` into `C:/` before `cmd.exe` is executed: + +``` +$ python -c "import sys;print(sys.argv)" /c foo +['-c', 'C:/', 'foo'] +``` + +So `cmd.exe` launched with no switch, opened an **interactive** session, printed +its banner, and read the hook's stdin JSON payload as console commands. The hook +never ran. Worse, a `>` token anywhere in that payload became a shell redirect, +creating empty junk files in the repo root (observed in the wild: `a`, `void`, +`export`, `UI`). + +The wrapper existed to protect paths containing parentheses (e.g. +`Claude Code Companion (C3)`) from bash word-splitting. It was never needed — +a double-quoted forward-slash path is parsed correctly by both bash and cmd: + +``` +"C:/.../python.exe" "U:/1. Projects/Claude Code Companion (C3)/cli/hook_dispatch.py" pretool +``` + +Hook commands are now emitted in that form on Windows (POSIX keeps `shlex.quote`). +Verified end-to-end: the dispatcher runs, returns its `additionalContext`, exits +0, and leaves no junk files, from a project path containing both a space and +parentheses. + +This wiring has now broken twice — first `cmd /c` (bash cannot resolve bare +`cmd`), then `cmd.exe /c`. `tests/test_install_mcp_entrypoint.py:: +test_hook_commands_are_not_wrapped_in_cmd_exe` is a regression guard: no +`cmd`/`cmd.exe` prefix, no single-quoted paths, no backslashes. + +**Existing projects are not repaired by upgrading** — the broken command string +lives in each project's `.claude/settings.local.json`. Re-run `c3 init` (or +`c3 install-mcp`) per project to rewrite it. + ## [2.60.0] - 2026-07-26 ### Added — Live repo map: `.c3/MAP.md` replaces the frozen instruction-doc tree diff --git a/CLAUDE.md b/CLAUDE.md index 2b2fe02..6f0cc56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,145 +34,15 @@ In plan mode, all c3_* read tools (search, read, compress, filter, validate, sta # Project Context -``` -claude-companion - v2/ - .gitattributes - .gitignore - .manifest.swo - .manifest.swp - .mcp.json - AGENTS.md - CHANGELOG.md - CLAUDE.md - LICENSE - LICENSING.md - README.md - SECURITY.md - THIRD_PARTY_LICENSES.md - c3.bat - install.bat - ... +3 more - .agents/ - .github/ - FUNDING.yml - copilot-instructions.md - workflows/ (2 files) - cli/ - __init__.py - _hook_utils.py - c3.py - docs.html - edits.html - hook_artifact.py - hook_auto_snapshot.py - hook_c3_signal.py - hook_c3read.py - hook_dispatch.py - hook_edit_ledger.py - hook_edit_unlock.py - hook_filter.py - hook_ghost_files.py - hook_pretool_enforce.py - ... +14 more - commands/ (3 files) - guide/ (7 files) - hub_ui/ (2 files) - tools/ (22 files) - ui/ (6 files) - commercial/ - info_01_efficiency.json - info_02_hierarchy.json - info_03_memory_flow.json - info_04_integration.json - scene_01.json - scene_02.json - scene_03.json - scene_04.json - core/ - __init__.py - config.py - ide.py - mcp_toml.py - web_security.py - docs/ - screenshots/ (11 files) - oracle/ - __init__.py - config.py - mcp_oracle.py - oracle_server.py - oracle_ui.html - services/ (17 files) - ui/ (12 files) - oracle-guide/ - README.md - api-reference.md - architecture.md - changelog.md - configuration.md - discovery-api.md - services/ - __init__.py - activity_log.py - agent_base.py - agents.py - artifact_defs.py - artifact_store.py - auto_memory.py - benchmark_dashboard.py - bitbucket_client.py - bitbucket_credentials.py - circuit_breaker.py - claude_md.py - compressor.py - context_snapshot.py - conversation_store.py - ... +53 more - bench/ (1 files) - tests/ - test_activity_reporter.py - test_aider_polyglot.py - test_antigravity_transition.py - test_artifact_store.py - test_artifact_tool.py - test_bitbucket_cli_smoke.py - test_bitbucket_client.py - test_bitbucket_config_fallback.py - test_bitbucket_credentials.py - test_bitbucket_tool.py - test_c3_bridge_project_artifacts.py - test_c3_shell.py - test_circuit_breaker.py - test_claude_md_merge.py - test_cli_smoke.py - ... +82 more - tui/ - __init__.py - backend.py - build.bat - build.sh - main.py - theme.tcss - screens/ (14 files) - widgets/ -``` - -## Tech Stack - -Python (Modern) - -## Key Files - -- `cli/tools/agent.py` — edited in 17 sessions -- `cli/mcp_server.py` — edited in 15 sessions -- `cli/tools/search.py` — edited in 11 sessions -- `cli/c3.py` — edited in 8 sessions -- `cli/hook_pretool_enforce.py` — edited in 5 sessions +Live repo map: `.c3/MAP.md` — tree, commands, entry points, module +one-liners. Read it BEFORE any file discovery. C3 refreshes it +automatically (edit hooks + first tool call); if it is missing or looks +stale, run `c3 map refresh`. Freshness state: `.c3/map.meta.json`. ## Key Facts (use c3_memory for more) - [validate] U:\1. Projects\Claude Code Companion (C3)\claude-companion - v2\cli\hub_ui\components\toasts.js has syntax er - Sub-projects (v2.44.0): three-way link — parent config subprojects[] (POSIX rel_path = source of truth), child config pa - Hub v2 (v2.44.0): cli/hub_ui.html + cli/hub_ui/ 22-file concat bundle served at / (hub.html frozen at /legacy until v2.4 -- Session summary (20260702): Files (modified): services/subprojects.py, .github/workflows/release.yml, pyproject.toml, cl -- Session summary (20260703): Files (modified): cli/tools/edit.py, cli/tools/read.py, tests/test_read_edit_parity.py, C:\U \ No newline at end of file +- Session summary (20260703): Files (modified): cli/tools/edit.py, cli/tools/read.py, tests/test_read_edit_parity.py, C:\U +- [validate] cli/ui/components/settings.js has syntax errors: - L106, Col 0: <> \ No newline at end of file diff --git a/README.md b/README.md index cdc0abc..f869927 100644 --- a/README.md +++ b/README.md @@ -298,12 +298,30 @@ with credentials disabled, and the vault is hard-excluded from the Oracle Discovery API. Since **v2.59.0** the Hub has a top-level **Credentials** view: manage the -global vault (`~/.c3`) and every registered project's entries from one place — -create / edit / delete / `.env` import / flag toggles, with shadowing shown -both ways ("shadows global" on project entries, "shadowed in N projects" on -globals). The write-only wire contract extends to the hub: values are -submitted inbound-only and **no hub route ever returns a stored value** (there -is no `reveal` on the hub at all). +global vault (`~/.c3`) and every registered project's entries from one place, +with overriding shown both ways ("overrides global" on project entries, +"overridden ×N" on globals). **v2.61.0** makes it navigable at scale: + +- **Cross-project search** — / or Ctrl/⌘-K. Free words + match name / description / env var / project; `project:` `scope:` `type:` + `storage:` `name:` `env:` `inject:` `agent:` `shadow:` qualifiers narrow + further. Results are **grouped by credential name**, so "where is + `STRIPE_KEY` defined and which one wins?" is one glance instead of forty + accordions. `agent:true` and `inject:true` are one-key exposure audits. +- **Per-credential settings drawer** — metadata, the two exposure switches + with their blast radius written out, an on-demand resolution check + + fingerprint, write-only value replacement, usage and override + relationships, and a separated danger zone. +- **Right-click context menu** on any row (also `⋯` and Shift+F10), + with typed confirmations replacing `window.confirm`: deleting or granting + agent-read access requires typing the credential's name. + +The write-only wire contract extends to the hub: values are submitted +inbound-only, **no hub route ever returns a stored value** (there is no +`reveal` on the hub at all), and search indexes metadata only. + +Full documentation: [`cli/guide/credentials.html`](cli/guide/credentials.html) +— open it in the app at `/guide/credentials.html`. ### Jira — Cloud + Data Center (v2.56.0) diff --git a/cli/c3.py b/cli/c3.py index 5e508b8..6b68ee8 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -92,7 +92,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.60.0" +__version__ = "2.61.0" def _command_deps() -> CommandDeps: @@ -5128,22 +5128,33 @@ def cmd_install_mcp(args): settings = _safe_read_json(settings_path, str(settings_path)) # Build hook commands using the Python executable that runs c3. - # On Windows, Claude Code executes hooks via /usr/bin/bash (Git Bash), which cannot - # parse Windows absolute paths containing parentheses (e.g. "(C3)"). Prefix with - # cmd.exe so it handles path resolution instead of bash. # - # Use "cmd.exe" WITH the extension, not bare "cmd": Git Bash does not resolve bare - # "cmd" on PATH, so the old "cmd /c …" prefix silently failed to launch any hook - # (verified: under bash, "cmd.exe /c '' ''" runs and writes the signal - # file; "cmd /c …" returns "cmd: command not found"). The single-quoted paths are - # correct — bash strips them and re-quotes for cmd.exe, preserving spaces/parens. - _hook_prefix = "cmd.exe /c " if sys.platform == "win32" else "" + # Windows: do NOT wrap in cmd.exe. Claude Code runs hooks through Git Bash, + # whose MSYS argument conversion rewrites a standalone "/c" into "C:/" before + # cmd.exe ever sees it — so cmd.exe starts INTERACTIVELY, prints its banner, + # and reads the hook's stdin JSON payload as console commands. The hook never + # runs, and any ">" token in the payload becomes a shell redirect that silently + # creates junk files in the repo root. + # + # Verified 2026-07-26 under Git Bash on Windows 11: + # python -c "import sys;print(sys.argv)" /c foo -> ['-c', 'C:/', 'foo'] + # cmd.exe /c '' -c "print('x')" -> banner, no execution + # '' '' posttool -> runs, stdin intact + # Both "cmd /c" (bash cannot resolve bare "cmd") and "cmd.exe /c" are broken; + # the wrapper was never needed. A double-quoted forward-slash path is parsed + # correctly by bash AND cmd, including paths containing spaces or parentheses + # (e.g. "Claude Code Companion (C3)"), which is why the wrapper was added. + def _hook_arg(raw: str) -> str: + if sys.platform == "win32": + return '"' + str(raw).replace("\\", "/") + '"' + return shlex.quote(str(raw)) + # v2.42: single dispatcher script per hook event instead of N separate # per-hook commands. One interpreter spawn per event; the dispatcher # (cli/hook_dispatch.py) runs all applicable sub-hooks in-process. _dispatch_base = ( - f"{_hook_prefix}{shlex.quote(sys.executable)} " - f"{shlex.quote(str(cli_dir / 'hook_dispatch.py'))}" + f"{_hook_arg(sys.executable)} " + f"{_hook_arg(str(cli_dir / 'hook_dispatch.py'))}" ) hook_pretool_cmd = f"{_dispatch_base} pretool" hook_posttool_cmd = f"{_dispatch_base} posttool" diff --git a/cli/guide/bitbucket.html b/cli/guide/bitbucket.html index 90de694..a7dd95e 100644 --- a/cli/guide/bitbucket.html +++ b/cli/guide/bitbucket.html @@ -77,6 +77,7 @@ Get Started Workflow Tools + Credentials Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/credentials.html b/cli/guide/credentials.html new file mode 100644 index 0000000..f7be75a --- /dev/null +++ b/cli/guide/credentials.html @@ -0,0 +1,554 @@ + + + + + + Credential Vault — C3 Guide + + + + + +
+ +
+ + + v2.61.0 +
+ + + + + +
+ +
+

Credential vault

+

A user-owned place for API keys and tokens that agents can use but never read. C3 decodes secrets at the subprocess boundary, so the value reaches your command and never reaches the model's context.

+
+ v2.58.0 vault + v2.59.0 Hub tab + v2.61.0 search + drawer + OS keyring + Injection-first + Write-only wire +
+
+ + +
+

Overview

+ +

The usual way an agent gets a secret is the worst way: you paste it into the chat. From that moment the value lives in the conversation transcript — stored, indexed, searchable, and replayed into every subsequent request. Deleting the message does not un-index it.

+ +

The C3 vault removes that step. You store the secret once; the agent refers to it by name. When a command actually needs the value, C3 decodes it into the subprocess environment and nowhere else.

+ +
+ vault (keyring) + → + c3_shell subprocess env + → + your command +
+
+ ✗ model context + ✗ transcript + ✗ HTTP response + ✗ .c3/config.json +
+ +
+

Write-only wire. No HTTP route in C3 — project server or Hub — ever returns a stored value. Entries are serialized through an explicit allowlist that structurally cannot emit one; rows carry a length and, on demand, a fingerprint. A canary test sweeps every credentials endpoint on every CI run and fails if a planted secret appears in any response body.

+
+ +

What this is not

+
    +
  • Not a team secret manager. It is local, single-user, and backed by your OS keyring. It does not sync, rotate, or issue short-lived credentials. If you need Vault or AWS Secrets Manager, use those — and store their bootstrap token here.
  • +
  • Not a substitute for scoping your tokens. The vault controls where a value travels inside C3. It cannot reduce what the token itself is allowed to do.
  • +
  • Not obfuscation dressed as encryption. Values sit in the OS keyring, which is exactly as strong as your OS login. Anything C3 could decode on its own would be reversible by anyone holding the code.
  • +
+
+ +
+ + +
+

Quick start

+ +
+
+ 1

Store it

+

The value is read from a hidden prompt — it never appears in your shell history.

+
c3 creds set NPM_TOKEN
+
+
+ 2

Confirm it landed

+

Names and metadata only; the value is not printed.

+
c3 creds list
+
+
+ 3

Use it

+

The agent names the secret; C3 injects it into the subprocess.

+
c3_shell(cmd='npm publish',
+         env_creds='NPM_TOKEN')
+
+
+ +

Anything shared across every project — a personal GitHub PAT, an OpenAI key — belongs in the global vault:

+
c3 creds set OPENAI_API_KEY --global
+ +

Bulk-import an existing .env file (each line becomes an entry of type env):

+
c3 creds import .env
+c3 creds import .env --global
+ +
+

Importing does not delete the source file. Once the values are in the vault, remove the .env (or confirm it is gitignored) — otherwise you have simply added a second copy.

+
+
+ +
+ + +
+

Where values live

+ + + + + + + + + + + + + + + + + + +
OS keyringThe default home for every value, under service c3-creds. Backed by Windows Credential Manager, macOS Keychain, or Linux Secret Service.
.c3/secrets.encValues larger than 1024 bytes (PEM keys, service-account JSON, .env blobs) — some keyring backends choke on long strings. Fernet-encrypted; the random master key is itself a keyring entry, so the file alone is useless.
.c3/config.jsonThe non-secret registry: name, scope, type, description, env var, flags, byte length, storage backend, created/updated timestamps. Never the value.
.c3/cred_state.jsonUsage counters — last used, use count. Written when a value is actually resolved for injection.
+ +

C3 adds secrets.enc and cred_state.json to .c3/.gitignore when the first credential is stored, so they cannot be committed even in projects that deliberately track .c3/.

+ +

Fingerprints

+

A fingerprint is the first 8 hex characters of sha256(value), computed live on request and never persisted. It answers "is the thing in the vault the thing I think it is?" — compare two machines, or confirm a rotation actually replaced the value — without revealing anything about it.

+
+ +
+ + +
+

Scopes & overriding

+ + + + + + + +
ScopeLives inVisible to
global~/.c3Every C3 project on this machine
project<project>/.c3That project only — and it overrides a same-named global entry there
+ +

Overriding is the mechanism for per-environment values. Keep a personal GITHUB_TOKEN in the global vault, then give one client project its own project-scoped GITHUB_TOKEN; work in that project resolves to the local one, everything else still gets the global.

+ +

The Hub shows the relationship from both directions: a project entry that wins over a global carries overrides global, and the global entry it beats carries overridden ×N with the project names on hover.

+ +
+

Resolution is realm-atomic. A name registered in the project realm resolves in the project realm or not at all — it never silently falls through to the global vault. Without this, a hostile repository could commit a .c3/config.json registering the name of one of your global secrets and have C3 hand it over. There is a test for exactly this.

+
+
+ +
+ + +
+

How agents use them

+ +

Three injection paths, all of which decode at the subprocess boundary:

+ +

1. Explicit env vars

+
c3_shell(cmd='npm publish', env_creds='NPM_TOKEN')
+c3_shell(cmd='terraform apply', env_creds='AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY')
+

Each named entry is exported into the child process under its env_var (defaulting to the entry name).

+ +

2. Inline templates

+
c3_shell(cmd='curl -H "Authorization: Bearer {{cred:API_KEY}}" https://api.example.com')
+

{{cred:NAME}} is expanded server-side, after the model has produced the command string. The model writes the placeholder; it never writes the secret.

+ +

3. Auto-inject

+

An entry flagged inject is exported into every c3_shell run in its scope without being named. Convenient for a token every build step needs — and correspondingly broader, since any command that runs there can read it from the environment.

+ +

Echo redaction

+

Any value decoded during a session is tracked process-locally, and output is scrubbed on the way back: a command that echoes the secret returns [cred:NAME] instead. This is a safety net, not a guarantee — a transformed value (base64-encoded, split across lines, hashed) will not match and will pass through.

+ +
+

Cross-project shells run with credentials disabled. c3_project(action='shell') cannot inject another project's vault. Otherwise "operate on project B" would become a credential-exfiltration primitive.

+
+
+ +
+ + +
+

Exposure flags

+ +

Two per-entry switches, both off by default, that control how far a value is allowed to travel. The Hub's settings drawer groups them under Exposure and spells out the blast radius of each.

+ + + + + + + + + + + + + + + +
FlagEffectCost
injectAuto-export into every c3_shell subprocess in this scopeEvery command that runs there — including third-party tooling you did not write — can read it from the environment.
agent_readablePermits c3_credentials(action='reveal') — the only value-returning actionThe plaintext enters the model's context and the conversation transcript, which are stored and searchable. Injection-only use does not need this flag.
+ +
+

agent_readable is user-only, by design. An agent may set it when creating an entry, but it can never raise the flag on an entry that already exists. Otherwise the first thing a confused — or prompt-injected — agent would do is grant itself read access to everything. Raising it is a deliberate act you perform in the Hub UI or the CLI, and in the Hub it requires typing the credential's name.

+
+ +

Reach for agent_readable only when the agent genuinely must reason about the value itself — pasting a key into a config file it is authoring, say. For "run this command with my token", injection is both sufficient and strictly safer.

+
+ +
+ + +
+

Hub UI

+ +

Run c3 hub and open the Credentials tab in the top bar. It manages the global vault and every registered project from one place. The same manager appears in each project's drill panel and in the per-project dashboard, so there is one interface to learn.

+ +

Two sub-tabs

+ + + + + + + + + +
Global vaultThe shared ~/.c3 store. Everything here is visible in every C3 project.
ProjectsEvery registered project, with its project-scoped entry count and — before you expand anything — badges for how many entries are agent-readable and how many override a global. Uninitialized projects are shown dimmed rather than hidden, so a missing .c3/ is visible instead of mysterious.
+ +
+

One project expands at a time. The Projects list is a single-open accordion. Before v2.61.0 every expanded project mounted its own manager with its own fetch loop, so ten open projects meant ten independent views drifting out of sync with each other after any change.

+
+ +

The settings drawer

+

Click any credential — or press ↵ on a focused row — to open its settings drawer. This is the only surface that edits an existing entry or replaces a value.

+ + + + + + + + + + +
SectionContents
GeneralDescription, type, and the env var used at injection. The name is immutable — there is no rename; create a new entry and delete the old one.
ExposureThe inject and agent_readable switches, each with its blast radius written out. Turning one on requires confirmation; turning it off is immediate.
SecretCheck resolution probes whether the stored value still decodes and returns a fingerprint. Replace secret… opens a write-only field that starts empty, is never prefilled, and is cleared the moment the request settles.
Usage & relationshipsCreated / updated / last used / use count / storage backend, plus which projects override this name or are overridden by it.
Danger zoneDelete — visually separated from everything else.
+ +

Context menu

+

Right-click any credential row for the full action set. The same menu opens from the ⋯ button on the row, and from Shift+F10 or the Menu key when the row is focused. Arrow keys navigate it; Esc closes it.

+ + + + + + + + + + + + + +
ItemNotes
Open settings…The drawer
Check resolutionResolvability + fingerprint
Replace secret…Opens the drawer with the replace field ready
Enable / disable auto-injectConfirmation required to enable
Allow / revoke agent readEnabling requires typing the credential name
Copy name · env var · fingerprintFingerprint is available only after a check. There is no copy-value item — the value is not in the browser to copy.
Open project drillJumps to that project's panel (project-scoped entries only)
Delete credential…Requires typing the credential name
+ +
+

Destructive actions state their blast radius. Deleting shows where the value is stored, that anything resolving the name will start failing, and — for a global entry — how many projects override it locally and are therefore unaffected. The confirm button stays disabled until you type the credential's name.

+
+ +

Importing a .env

+

Import .env in the manager toolbar takes pasted KEY=VALUE lines. Comments and export prefixes are tolerated; each line becomes an entry of type env. Existing names are skipped rather than overwritten, and the response reports created and skipped counts by name.

+
+ +
+ + + + +
+ + +
+

CLI commands

+ + + + + + + + + + +
CommandDescription
c3 creds set NAMECreate or update. The value is read from a hidden getpass prompt by default; --stdin accepts a piped or multiline value, and --value exists but puts the secret in your shell history. Metadata flags: --type {token,env,multiline}, --env-var, --desc, --inject, --agent-readable. --global targets the shared vault.
c3 creds listMerged view — project entries plus the globals visible here. Names and metadata only.
c3 creds get NAMEEntry metadata, masked. --show prints the value to your terminal (not the agent's).
c3 creds rm NAMEDelete the value and the registry entry. --global to target the shared vault.
c3 creds import FILEImport KEY=VALUE lines. Names already registered in the target scope are skipped unless you pass --overwrite.
+ +

c3 creds get --show is the escape hatch for when you need to see a value — reading it into your own clipboard, not the agent's context. It is a local terminal command and has nothing to do with the agent_readable flag.

+
+ +
+ + +
+

Tool actions

+ +

What the agent can call through c3_credentials. Read actions are safe in plan mode.

+ + + + + + + + + + + +
ActionGroupDescription
listReadMerged registry — names, scope, type, flags, usage. Never values.
describeReadMetadata, storage backend, and a live fingerprint.
checkReadDoes the stored value still decode?
setWriteCreate or replace an entry. agent_readable may be set at creation only.
deleteWriteRemove the value and the registry entry.
revealGatedThe only value-returning action — refused unless you enabled agent_readable on that entry.
+ +

The full parameter reference lives on the tool card.

+
+ +
+ + +
+

Audit trail

+ +

Every mutation and every reveal is logged by name — never by value.

+ + + + + + + + + + + + + + +
Project scopeThe target project's .c3/activity_log.jsonl (event cred_action) and its edit ledger, as cred://<NAME>.
Global scope~/.c3/activity_log.jsonl, so the shared vault keeps its own trail independent of whichever project you were in.
OriginHub-initiated changes are tagged via: "hub", which distinguishes them from CLI and agent-initiated ones.
+ +

To review what happened to one credential:

+
c3_edits(action='history', file='cred://NPM_TOKEN')
+ +
+

The vault is excluded from the Oracle Discovery API. External LLMs reaching C3 over MCP/HTTP cannot see credential tools at all — not the metadata, not the names. The exclusion is a hard exclusion, not a permission check.

+
+
+ +
+ + +
+

Troubleshooting

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymptomCause & fix
The 'keyring' package is requiredpip install keyring. It is a declared dependency, but an in-place upgrade can leave it missing.
Check reports ✗ unresolvableThe registry entry exists but the value is gone — usually a keyring reset, a new OS user profile, or a deleted .c3/secrets.enc. Re-store the value with c3 creds set NAME.
Hub shows not initializedThat project has no .c3/ directory. Run c3 init there. The Hub deliberately refuses to create .c3/ directories remotely (it returns 409 needs_init) rather than scattering them across your disk.
Injected variable is empty in the commandCheck the entry's env_var — it defaults to the credential name, but if it was set to something else, that is the variable your command must read.
reveal refusedWorking as designed: agent_readable is off. If the agent genuinely needs the value, turn it on yourself in the Hub drawer or with c3 creds set NAME --agent-readable. If it only needs to run something, use env_creds instead.
Global entry ignored in one projectThat project has a project-scoped entry of the same name overriding it. Search shadow:true to see every override in play.
+
+ +
+ +

+ The vault is deliberately small: services/credential_store.py holds the storage layer, and the write-only wire is enforced by an allowlist serializer plus an endpoint-sweep canary test. If you find a route that returns a value, that is a security bug — please report it. +

+ +
+
+ + + diff --git a/cli/guide/getting-started.html b/cli/guide/getting-started.html index f002ad7..3bce7b4 100644 --- a/cli/guide/getting-started.html +++ b/cli/guide/getting-started.html @@ -20,6 +20,7 @@ Get Started Workflow Tools + Credentials Bitbucket Oracle ♥ Sponsor @@ -38,6 +39,7 @@ ▶️ First Session 🔒 Permissions 🌐 Web UI & Hub + 🔐 Credentials 🪣 Bitbucket 🗑️ Uninstall @@ -186,6 +188,14 @@

What init creates

Re-init / upgrade

Running c3 init on an existing project is safe — it merges new config without overwriting your customizations.

+
+ ⚠️ +
+ Upgrading to v2.61.0 on Windows? Re-run c3 init in every project. + Versions before v2.61.0 wrote Windows hook commands wrapped in cmd.exe /c. Git Bash rewrites a standalone /c into C:/ before cmd.exe runs, so the hook silently never executed — and a > anywhere in the hook's stdin payload became a shell redirect that created empty junk files in the repo root. The generator is fixed, but the broken command string lives in each project's .claude/settings.local.json: upgrading the package does not repair existing projects. Re-run c3 init (or c3 install-mcp) per project to rewrite it. +
+
+
🛡️
@@ -401,6 +411,33 @@

Project Hub


+ +
+
🔐 Credentials (optional)
+ +

Stop pasting API keys into the chat. Store them once, and the agent uses them by name — C3 decodes the value into the subprocess environment, so it never enters the model's context or the conversation transcript.

+ +
# Value is read from a hidden prompt — never in shell history
+c3 creds set NPM_TOKEN
+
+# Shared across every C3 project on this machine
+c3 creds set OPENAI_API_KEY --global
+
+# Names and metadata only — the value is never printed
+c3 creds list
+ +

The agent then names the secret instead of knowing it:

+
c3_shell(cmd='npm publish', env_creds='NPM_TOKEN')
+ +

Manage everything visually in the Hub's Credentials tab — search across all projects with /, open a settings drawer per credential, right-click for actions.

+ +

+ Storage internals, the global/project override model, exposure flags, search qualifiers, and troubleshooting are on the dedicated credential vault guide. +

+
+ +
+
🪣 Bitbucket Data Center (optional)
diff --git a/cli/guide/index.html b/cli/guide/index.html index 908a098..e55c75b 100644 --- a/cli/guide/index.html +++ b/cli/guide/index.html @@ -222,6 +222,7 @@ Get Started Workflow Tools + Credentials Bitbucket Oracle ♥ Sponsor @@ -254,6 +255,9 @@ 🔧 Tools Reference + + 🔐 Credential Vault + 🪣 Bitbucket @@ -312,6 +316,11 @@

Claude Code Companion

+ + 🔐 + + + 🪣 @@ -322,7 +331,7 @@

Claude Code Companion

- + 🧠 diff --git a/cli/guide/oracle.html b/cli/guide/oracle.html index 6630b5a..bbc3558 100644 --- a/cli/guide/oracle.html +++ b/cli/guide/oracle.html @@ -75,6 +75,7 @@ Get Started Workflow Tools + Credentials Bitbucket Oracle ♥ Sponsor diff --git a/cli/guide/tools.html b/cli/guide/tools.html index 2005461..f30f1bf 100644 --- a/cli/guide/tools.html +++ b/cli/guide/tools.html @@ -79,6 +79,7 @@ Get Started Workflow Tools + Credentials Bitbucket Oracle ♥ Sponsor @@ -1279,7 +1280,9 @@

Examples

c3_shell(cmd='npm publish', env_creds='NPM_TOKEN') c3_shell(cmd='curl -H "Authorization: Bearer {{cred:API_KEY}}" https://api.example.com') -

Surfaces: the c3 creds CLI (set / get / list / rm / import .env, --global for the shared scope), a per-project dashboard Credentials tab, and — since v2.59.0 — a top-level Credentials view in the Hub: a Global-vault manager plus a per-project browser with shadowing shown both ways ("shadows global" on project entries, "shadowed in N projects" on globals); the Hub's per-project drill panel manages too. No HTTP route ever returns a stored value (write-only wire contract, endpoint-sweep tested), every mutation is ledger-logged by name, the vault is hard-excluded from the Oracle Discovery API, and cross-project (c3_project) shells run with credentials disabled.

+

Surfaces: the c3 creds CLI (set / get / list / rm / import .env, --global for the shared scope), a per-project dashboard Credentials tab, and a top-level Credentials view in the Hub (v2.59.0) managing the global vault and every registered project, with overriding shown both ways. Since v2.61.0 that view adds cross-project search (/ or Ctrl/⌘-K; project: scope: inject: agent: shadow: qualifiers, results grouped by credential name), a per-credential settings drawer (metadata, exposure switches, resolution check, write-only value replacement, usage & overrides, delete), and a right-click context menu; destructive and exposure-raising actions require typing the credential name. No HTTP route ever returns a stored value (write-only wire contract, endpoint-sweep tested), every mutation is ledger-logged by name, the vault is hard-excluded from the Oracle Discovery API, and cross-project (c3_project) shells run with credentials disabled.

+ +

See the credential vault guide for storage internals, the scope/override model, injection paths, exposure-flag semantics, the Hub UI tour, search qualifiers, and troubleshooting.

diff --git a/cli/guide/workflow.html b/cli/guide/workflow.html index ea22701..9745d7d 100644 --- a/cli/guide/workflow.html +++ b/cli/guide/workflow.html @@ -222,6 +222,7 @@ Get Started Workflow Tools + Credentials Bitbucket Oracle ♥ Sponsor diff --git a/cli/hub_ui/components/hub_credentials.js b/cli/hub_ui/components/hub_credentials.js index 29dea54..572b364 100644 --- a/cli/hub_ui/components/hub_credentials.js +++ b/cli/hub_ui/components/hub_credentials.js @@ -1,14 +1,703 @@ // ─── Hub credentials: global vault + cross-project manager ───── // CredsManager is the shared manager (also used by the drill Credentials tab); -// HubCredentials is the top-level mainView='creds' page. +// HubCredentials is the top-level mainView='creds' page and owns the +// cross-project search. CredDrawer is the per-credential settings surface, +// CredMenu the right-click / kebab context menu, CredConfirm the typed +// confirmation standing in for window.confirm on anything destructive or +// exposure-raising. // Write-only wire: values are submitted inbound-only and never returned by // any hub route — rows show length + fingerprint, never the secret itself. +// Nothing below may render, copy or cache a value; there is no reveal path. const HUB_CREDS_EMPTY_FORM = { name: '', value: '', scope: 'project', type: 'token', description: '', env_var: '', agent_readable: false, inject: false, }; +// path falsy everywhere below → the shared global vault (~/.c3). +const credApi = { + listProject: (path) => + api.get('/api/projects/credentials?path=' + encodeURIComponent(path)), + overview: () => api.get('/api/hub/credentials/overview'), + save: (path, payload) => api.post('/api/projects/credentials', + path ? Object.assign({ path }, payload) : payload), + check: (path, name) => api.post( + `/api/projects/credentials/${encodeURIComponent(name)}/check`, path ? { path } : {}), + remove: (path, name, scope) => api.del( + `/api/projects/credentials/${encodeURIComponent(name)}?scope=${scope}` + + (path ? '&path=' + encodeURIComponent(path) : '')), +}; + +const credWhen = (iso) => (iso + ? String(iso).replace('T', ' ').replace(/\.\d+/, '').replace(/\+.*$/, '').replace(/Z$/, '') + : '—'); +// Exposure weight — drives the "exposure" sort so the risky rows float up. +const credRisk = (e) => (e.agent_readable ? 2 : 0) + (e.inject ? 1 : 0); + +const credCopy = (text, label) => { + if (!text) return; + const done = () => notify(`Copied ${label}`); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, () => notify('Copy failed')); + } else { notify('Clipboard unavailable'); } +}; + +function credSortEntries(list, mode) { + const byName = (a, b) => String(a.name).localeCompare(String(b.name)); + const out = list.slice(); + if (mode === 'recent') { + out.sort((a, b) => String(b.last_used || '').localeCompare(String(a.last_used || '')) || byName(a, b)); + } else if (mode === 'used') { + out.sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || byName(a, b)); + } else if (mode === 'exposure') { + out.sort((a, b) => credRisk(b) - credRisk(a) || byName(a, b)); + } else { out.sort(byName); } + return out; +} + +// ── Search over the metadata the allowlist serializer already returns ── +// Free tokens are AND-matched against name/description/env_var/owner; +// `key:value` qualifiers narrow further. No value is ever indexed because +// no route ever returns one. +const CRED_QUALIFIERS = ['project', 'scope', 'type', 'storage', 'name', 'env', + 'inject', 'agent', 'shadow']; + +function parseCredQuery(raw) { + const terms = []; + const quals = []; + String(raw || '').trim().split(/\s+/).filter(Boolean).forEach((tok) => { + const m = tok.match(/^([A-Za-z]+):(.*)$/); + if (m && CRED_QUALIFIERS.indexOf(m[1].toLowerCase()) !== -1) { + quals.push({ key: m[1].toLowerCase(), val: m[2].toLowerCase() }); + } else { terms.push(tok.toLowerCase()); } + }); + return { terms, quals, empty: !terms.length && !quals.length }; +} + +const credFlagWanted = (v) => v === '' || v === 'true' || v === 'yes' || v === '1'; + +// rec = { entry, projectName, projectPath }; projectPath '' → global vault. +function credRecordMatches(rec, q) { + const e = rec.entry; + const hay = [e.name, e.description, e.env_var, e.type, e.storage, e.scope, + rec.projectName, rec.projectPath, rec.projectPath ? '' : 'global vault'] + .filter(Boolean).join(' ').toLowerCase(); + for (let i = 0; i < q.terms.length; i++) { + if (hay.indexOf(q.terms[i]) === -1) return false; + } + for (let i = 0; i < q.quals.length; i++) { + const key = q.quals[i].key; + const val = q.quals[i].val; + const owner = `${rec.projectName || ''} ${rec.projectPath || ''}`.toLowerCase(); + if (key === 'project') { + const hit = rec.projectPath ? owner.indexOf(val) !== -1 : 'global vault'.indexOf(val) !== -1; + if (!hit) return false; + } else if (key === 'scope') { + if (String(e.scope || '').toLowerCase().indexOf(val) !== 0) return false; + } else if (key === 'type') { + if (String(e.type || 'token').toLowerCase().indexOf(val) !== 0) return false; + } else if (key === 'storage') { + if (String(e.storage || '').toLowerCase().indexOf(val) !== 0) return false; + } else if (key === 'name') { + if (String(e.name || '').toLowerCase().indexOf(val) === -1) return false; + } else if (key === 'env') { + if (String(e.env_var || '').toLowerCase().indexOf(val) === -1) return false; + } else if (key === 'inject') { + if (!!e.inject !== credFlagWanted(val)) return false; + } else if (key === 'agent') { + if (!!e.agent_readable !== credFlagWanted(val)) return false; + } else if (key === 'shadow') { + const involved = !!e.shadows_global || ((e.shadowed_in || []).length > 0); + if (involved !== credFlagWanted(val)) return false; + } + } + return true; +} + +// ── Confirmation specs, shared by the rows, the menu and the drawer ── +// Raising exposure and deleting are the only two irreversible-ish moves; +// both get an explicit, readable modal instead of window.confirm. +function credExposureConfirm(entry, field, envVar, owner, onConfirm) { + if (field === 'agent_readable') { + return { + title: 'Allow the agent to read this value?', tone: 'error', + confirmLabel: 'Allow agent access', requireText: entry.name, + intro: Enabling agent_readable lets the agent pull the plaintext of{' '} + {entry.name} into its context., + bullets: [ + 'The value then lands in conversation transcripts, which are stored and searchable.', + 'Injection-only use (c3_shell env_creds, {{cred:NAME}}) does NOT need this flag.', + `Scope: ${owner}.`, + ], + onConfirm, + }; + } + return { + title: 'Auto-inject on every shell run?', tone: 'warn', + confirmLabel: 'Enable auto-inject', + intro: {entry.name} will be exported as{' '} + ${envVar || entry.name} into every{' '} + c3_shell subprocess in {owner}., + bullets: ['Any command that runs there — including third-party tooling — can read it from the environment.'], + onConfirm, + }; +} + +function credDeleteConfirm(entry, owner, onConfirm) { + const shadowed = (entry.shadowed_in || []).length; + const bullets = [`Stored in ${owner} (${entry.storage || 'keyring'}).`, + 'Anything resolving this name will start failing.']; + if (entry.scope === 'global' && shadowed) { + bullets.push(`${shadowed} project${shadowed === 1 ? '' : 's'} override this name locally and are unaffected.`); + } + if (entry.shadows_global) { + bullets.push('The same-named global entry will take over for this project.'); + } + return { + title: `Delete '${entry.name}'?`, tone: 'error', confirmLabel: 'Delete credential', + requireText: entry.name, + intro: The stored value is destroyed. This cannot be undone — C3 keeps no copy., + bullets, onConfirm, + }; +} + +// ── Right-click / kebab context menu ─────────────────────────── +// items: [{label, icon, onClick, danger, disabled, hint} | {separator:true}] +function CredMenu({ x, y, items, onClose }) { + const boxRef = useRef(null); + const [pos, setPos] = useState({ left: -9999, top: -9999 }); + const [active, setActive] = useState(-1); + const rows = items.filter(it => !it.separator); + + useEffect(() => { + const el = boxRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + setPos({ + left: Math.max(6, Math.min(x, window.innerWidth - r.width - 6)), + top: Math.max(6, Math.min(y, window.innerHeight - r.height - 6)), + }); + el.focus(); + }, [x, y]); + + useEffect(() => { + const bail = () => onClose(); + window.addEventListener('resize', bail); + window.addEventListener('scroll', bail, true); + return () => { + window.removeEventListener('resize', bail); + window.removeEventListener('scroll', bail, true); + }; + }, [onClose]); + + const run = (it) => { if (it.disabled) return; onClose(); it.onClick(); }; + + const onKeyDown = (e) => { + if (e.key === 'Escape') { e.preventDefault(); onClose(); return; } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (!rows.length) return; + const dir = e.key === 'ArrowDown' ? 1 : -1; + let n = active; + for (let i = 0; i < rows.length; i++) { + n = (n + dir + rows.length) % rows.length; + if (!rows[n].disabled) break; + } + setActive(n); + return; + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (rows[active]) run(rows[active]); + } + }; + + let idx = -1; + return ( +
{ e.preventDefault(); onClose(); }} + style={{ position: 'fixed', inset: 0, zIndex: 290 }}> +
e.stopPropagation()} + style={{ + position: 'fixed', left: pos.left, top: pos.top, minWidth: 224, + background: T.surface, border: `1px solid ${T.borderHover}`, + borderRadius: 8, padding: '5px 0', outline: 'none', fontSize: 12, + boxShadow: '0 12px 34px #00000075', + }}> + {items.map((it, i) => { + if (it.separator) { + return
; + } + idx += 1; + const mine = idx; + const color = it.disabled ? T.textDim : (it.danger ? T.error : T.text); + return ( +
setActive(mine)} + onClick={() => run(it)} + style={{ + display: 'flex', alignItems: 'center', gap: 9, padding: '7px 13px', + cursor: it.disabled ? 'default' : 'pointer', color, + background: (active === mine && !it.disabled) + ? (it.danger ? `${T.error}18` : T.surfaceAlt) : 'transparent', + }}> + + {it.label} + {it.hint && ( + {it.hint} + )} +
+ ); + })} +
+
+ ); +} + +// ── Typed confirmation (stands in for window.confirm) ────────── +function CredConfirm({ spec, onClose }) { + const [typed, setTyped] = useState(''); + const [busy, setBusy] = useState(false); + if (!spec) return null; + const need = spec.requireText; + const ok = !need || typed.trim() === need; + const tone = spec.tone === 'warn' ? T.warn : T.error; + const go = async () => { + if (!ok || busy) return; + setBusy(true); + try { await spec.onConfirm(); } finally { setBusy(false); onClose(); } + }; + return ( + +
{spec.intro}
+ {(spec.bullets || []).length > 0 && ( +
    + {spec.bullets.map((b, i) =>
  • {b}
  • )} +
+ )} + {need && ( +
+ Type {need} to confirm + setTyped(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') go(); }} className="mono" + style={mdlInputStyle()} autoComplete="off" spellCheck={false} /> +
+ )} + + Cancel + + {busy ? 'Working…' : (spec.confirmLabel || 'Confirm')} + + +
+ ); +} + +// ── Per-credential settings drawer ───────────────────────────── +// The only surface that replaces a value or moves an exposure switch. +// The secret field starts empty, is never prefilled, and is cleared the +// moment the request settles. +const CredSec = ({ title, note, children, tone }) => ( +
+
{title}
+ {note &&
{note}
} + {children} +
+); + +const CredKV = ({ k, v }) => ( +
+ {k} + {v} +
+); + +function CredSwitch({ on, label, note, tone, onToggle, disabled }) { + const c = on ? (tone || T.accent) : T.textMuted; + return ( +
+ +
+
{label}
+
{note}
+
+
+ ); +} + +function CredDrawer({ entry, path, projectName, onClose, onChanged, initialReplace }) { + const [meta, setMeta] = useState({ + description: entry.description || '', type: entry.type || 'token', + env_var: entry.env_var || '', + }); + const [flags, setFlags] = useState({ + inject: !!entry.inject, agent_readable: !!entry.agent_readable, + }); + const [secret, setSecret] = useState(''); + const [replaceOpen, setReplaceOpen] = useState(!!initialReplace); + const [chk, setChk] = useState(null); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + const [confirm, setConfirm] = useState(null); + + const owner = entry.scope === 'global' + ? 'the global vault (~/.c3)' + : (projectName || path || 'this project'); + const dirty = meta.description !== (entry.description || '') + || meta.type !== (entry.type || 'token') + || meta.env_var !== (entry.env_var || ''); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape' && !confirm) onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, confirm]); + + const patch = async (fields, msg) => { + setBusy(true); setErr(''); + try { + const resp = await credApi.save(path, + Object.assign({ name: entry.name, scope: entry.scope }, fields)); + if (resp && resp.error) setErr(resp.error); + else { notify(msg); if (onChanged) onChanged(); } + } catch (e) { setErr(apiErr(e)); } + setBusy(false); + }; + + const applyFlag = (field, next) => { + setFlags(prev => Object.assign({}, prev, { [field]: next })); + return patch({ [field]: next }, + `${field === 'inject' ? 'Auto-inject' : 'Agent access'} ${next ? 'enabled' : 'disabled'} for '${entry.name}'`); + }; + + const toggle = (field) => { + const next = !flags[field]; + if (!next) { applyFlag(field, false); return; } + setConfirm(credExposureConfirm(entry, field, meta.env_var, owner, + () => applyFlag(field, true))); + }; + + const runCheck = async () => { + setBusy(true); setErr(''); + try { setChk(await credApi.check(path, entry.name)); } + catch (e) { setErr(apiErr(e)); } + setBusy(false); + }; + + // Full re-store: set_credential rewrites the entry, so every field the + // drawer knows about rides along or it would be silently reset. + const replaceSecret = async () => { + if (!secret) return; + setBusy(true); setErr(''); + try { + const resp = await credApi.save(path, { + name: entry.name, scope: entry.scope, value: secret, type: meta.type, + description: meta.description, env_var: meta.env_var, + agent_readable: flags.agent_readable, inject: flags.inject, + }); + if (resp && resp.error) setErr(resp.error); + else { notify(`Replaced the value of '${entry.name}'`); setChk(null); if (onChanged) onChanged(); } + } catch (e) { setErr(apiErr(e)); } + setSecret(''); setReplaceOpen(false); setBusy(false); + }; + + const doDelete = () => setConfirm(credDeleteConfirm(entry, owner, async () => { + try { + await credApi.remove(path, entry.name, entry.scope); + notify(`Deleted '${entry.name}'`); + if (onChanged) onChanged(); + onClose(); + } catch (e) { setErr(apiErr(e)); } + })); + + const fld = drillFieldStyle({ width: '100%', boxSizing: 'border-box' }); + const lbl = { fontSize: 11, color: T.textMuted, marginBottom: 4, display: 'block' }; + const shadowedIn = entry.shadowed_in || []; + + return ( + +
+
+ {/* Header */} +
+ +
+
+ {entry.name} +
+
+ {entry.scope} + {entry.type || 'token'} + {entry.storage || 'keyring'} + {!!entry.inject && inject} + {!!entry.agent_readable && agent_readable} +
+
in {owner}
+
+ +
+ + {err && ( +
{err}
+ )} + +
+ + Description + setMeta(Object.assign({}, meta, { description: e.target.value }))} /> +
+
+ Type + +
+
+ Env var (default: name) + setMeta(Object.assign({}, meta, { env_var: e.target.value }))} /> +
+
+
+ patch(meta, `Updated '${entry.name}'`)}>Save changes +
+
+ + + toggle('inject')} /> + toggle('agent_readable')} /> + + + +
+ + Check resolution + + {chk && ( + + {chk.resolvable ? `✓ resolves · ${chk.fingerprint}` : '✗ unresolvable'} + + )} + •••• len={entry.value_len} +
+ {!replaceOpen ? ( +
+ setReplaceOpen(true)}> + Replace secret… + +
+ ) : ( +
+ New value (write-only — never echoed back) + {meta.type === 'multiline' ? ( +