code-context-control 2.57.1__py3-none-any.whl → 2.59.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/c3.py +147 -2
- cli/commands/parser.py +42 -0
- cli/guide/tools.html +41 -0
- cli/hub_server.py +273 -2
- cli/hub_ui/app.js +4 -2
- cli/hub_ui/components/drill_panel.js +2 -0
- cli/hub_ui/components/drill_views.js +7 -1
- cli/hub_ui/components/hub_credentials.js +516 -0
- cli/hub_ui/components/topbar.js +1 -1
- cli/mcp_server.py +59 -4
- cli/server.py +147 -0
- cli/tools/credentials.py +239 -0
- cli/tools/project.py +5 -1
- cli/tools/shell.py +55 -5
- cli/ui/app.js +4 -0
- cli/ui/components/credentials.js +365 -0
- cli/ui/icons.js +2 -0
- {code_context_control-2.57.1.dist-info → code_context_control-2.59.0.dist-info}/METADATA +49 -2
- {code_context_control-2.57.1.dist-info → code_context_control-2.59.0.dist-info}/RECORD +25 -21
- services/claude_md.py +1 -0
- services/credential_store.py +643 -0
- {code_context_control-2.57.1.dist-info → code_context_control-2.59.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.57.1.dist-info → code_context_control-2.59.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.57.1.dist-info → code_context_control-2.59.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.57.1.dist-info → code_context_control-2.59.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
|
|
|
92
92
|
# Config
|
|
93
93
|
CONFIG_DIR = ".c3"
|
|
94
94
|
CONFIG_FILE = ".c3/config.json"
|
|
95
|
-
__version__ = "2.
|
|
95
|
+
__version__ = "2.59.0"
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
def _command_deps() -> CommandDeps:
|
|
@@ -248,7 +248,7 @@ _C3_MCP_ALLOW = [
|
|
|
248
248
|
"mcp__c3__c3_memory", "mcp__c3__c3_validate", "mcp__c3__c3_edit",
|
|
249
249
|
"mcp__c3__c3_agent", "mcp__c3__c3_delegate", "mcp__c3__c3_edits",
|
|
250
250
|
"mcp__c3__c3_impact", "mcp__c3__c3_shell", "mcp__c3__c3_bitbucket",
|
|
251
|
-
"mcp__c3__c3_jira",
|
|
251
|
+
"mcp__c3__c3_jira", "mcp__c3__c3_credentials",
|
|
252
252
|
"mcp__c3__c3_project", "mcp__c3__c3_task", "mcp__c3__c3_artifacts",
|
|
253
253
|
]
|
|
254
254
|
|
|
@@ -5738,6 +5738,150 @@ def _bb_cmd_set_default(args, project_path: str) -> None:
|
|
|
5738
5738
|
print(f"[OK] Default repo: {args.project}/{args.repo}")
|
|
5739
5739
|
|
|
5740
5740
|
|
|
5741
|
+
def cmd_creds(args):
|
|
5742
|
+
"""Credential vault management (global + per-project scopes)."""
|
|
5743
|
+
sub = getattr(args, "creds_cmd", None)
|
|
5744
|
+
if not sub:
|
|
5745
|
+
print("Usage: c3 creds {set,get,list,rm,import} [args]")
|
|
5746
|
+
return
|
|
5747
|
+
|
|
5748
|
+
project_path = getattr(args, "project_path", ".") or "."
|
|
5749
|
+
|
|
5750
|
+
if sub == "set":
|
|
5751
|
+
_creds_cmd_set(args, project_path)
|
|
5752
|
+
elif sub == "get":
|
|
5753
|
+
_creds_cmd_get(args, project_path)
|
|
5754
|
+
elif sub == "list":
|
|
5755
|
+
_creds_cmd_list(args, project_path)
|
|
5756
|
+
elif sub == "rm":
|
|
5757
|
+
_creds_cmd_rm(args, project_path)
|
|
5758
|
+
elif sub == "import":
|
|
5759
|
+
_creds_cmd_import(args, project_path)
|
|
5760
|
+
else:
|
|
5761
|
+
print(f"Unknown creds subcommand: {sub}")
|
|
5762
|
+
|
|
5763
|
+
|
|
5764
|
+
def _creds_scope(args) -> str:
|
|
5765
|
+
return "global" if getattr(args, "use_global", False) else "project"
|
|
5766
|
+
|
|
5767
|
+
|
|
5768
|
+
def _creds_entry_line(name: str, entry: dict) -> str:
|
|
5769
|
+
flags = [f for f in ("inject", "agent_readable") if entry.get(f)]
|
|
5770
|
+
parts = [
|
|
5771
|
+
f"{name:<24} {entry.get('scope', '?'):<8} {entry.get('type', 'token'):<10}"
|
|
5772
|
+
f" len={entry.get('value_len', '?')}",
|
|
5773
|
+
]
|
|
5774
|
+
if entry.get("env_var"):
|
|
5775
|
+
parts.append(f"env_var={entry['env_var']}")
|
|
5776
|
+
if flags:
|
|
5777
|
+
parts.append("[" + ",".join(flags) + "]")
|
|
5778
|
+
if entry.get("description"):
|
|
5779
|
+
parts.append(f"-- {entry['description']}")
|
|
5780
|
+
return " ".join(parts)
|
|
5781
|
+
|
|
5782
|
+
|
|
5783
|
+
def _creds_cmd_set(args, project_path: str) -> None:
|
|
5784
|
+
import getpass
|
|
5785
|
+
|
|
5786
|
+
from services import credential_store as cred_store
|
|
5787
|
+
|
|
5788
|
+
value = getattr(args, "value", "") or ""
|
|
5789
|
+
if getattr(args, "stdin", False):
|
|
5790
|
+
value = sys.stdin.read().rstrip("\n")
|
|
5791
|
+
if not value:
|
|
5792
|
+
value = getpass.getpass(f"Value for {args.name}: ")
|
|
5793
|
+
if not value:
|
|
5794
|
+
print("Cancelled -- value required.")
|
|
5795
|
+
return
|
|
5796
|
+
scope = _creds_scope(args)
|
|
5797
|
+
try:
|
|
5798
|
+
entry = cred_store.set_credential(
|
|
5799
|
+
args.name, value,
|
|
5800
|
+
scope=scope, project_path=project_path,
|
|
5801
|
+
description=getattr(args, "desc", "") or "",
|
|
5802
|
+
ctype=getattr(args, "ctype", "token") or "token",
|
|
5803
|
+
env_var=getattr(args, "env_var", "") or "",
|
|
5804
|
+
agent_readable=bool(getattr(args, "agent_readable", False)),
|
|
5805
|
+
inject=bool(getattr(args, "inject", False)),
|
|
5806
|
+
)
|
|
5807
|
+
except (cred_store.CredentialError, RuntimeError) as exc:
|
|
5808
|
+
print(f"[error] {exc}")
|
|
5809
|
+
return
|
|
5810
|
+
print(f"[OK] Stored credential '{args.name}' "
|
|
5811
|
+
f"(scope={scope}, storage={entry['storage']}, len={entry['value_len']})")
|
|
5812
|
+
if entry["agent_readable"]:
|
|
5813
|
+
print("[warn] agent_readable=true -- the agent can read this value "
|
|
5814
|
+
"into its context and transcripts.")
|
|
5815
|
+
|
|
5816
|
+
|
|
5817
|
+
def _creds_cmd_get(args, project_path: str) -> None:
|
|
5818
|
+
from services import credential_store as cred_store
|
|
5819
|
+
|
|
5820
|
+
entry = cred_store.get_entry(args.name, project_path=project_path)
|
|
5821
|
+
if not entry:
|
|
5822
|
+
print(f"[error] no credential named '{args.name}'")
|
|
5823
|
+
return
|
|
5824
|
+
print(_creds_entry_line(args.name, entry))
|
|
5825
|
+
print(f"storage={entry.get('storage', 'keyring')} "
|
|
5826
|
+
f"created={entry.get('created', '?')} updated={entry.get('updated', '?')}")
|
|
5827
|
+
fp = cred_store.fingerprint(args.name, project_path=project_path)
|
|
5828
|
+
print(f"fingerprint={fp or 'unresolvable'}")
|
|
5829
|
+
if getattr(args, "show", False):
|
|
5830
|
+
value = cred_store.get_value(args.name, project_path=project_path)
|
|
5831
|
+
print(value if value is not None else "[error] value missing from store")
|
|
5832
|
+
|
|
5833
|
+
|
|
5834
|
+
def _creds_cmd_list(args, project_path: str) -> None:
|
|
5835
|
+
from services import credential_store as cred_store
|
|
5836
|
+
|
|
5837
|
+
entries = cred_store.list_entries(project_path)
|
|
5838
|
+
if not entries:
|
|
5839
|
+
print("No credentials registered. Use `c3 creds set NAME` "
|
|
5840
|
+
"(add --global for all projects).")
|
|
5841
|
+
return
|
|
5842
|
+
print(f"{len(entries)} credential(s) — project scope shadows global:")
|
|
5843
|
+
for name, entry in entries.items():
|
|
5844
|
+
print(" " + _creds_entry_line(name, entry))
|
|
5845
|
+
|
|
5846
|
+
|
|
5847
|
+
def _creds_cmd_rm(args, project_path: str) -> None:
|
|
5848
|
+
from services import credential_store as cred_store
|
|
5849
|
+
|
|
5850
|
+
scope = _creds_scope(args)
|
|
5851
|
+
entry = cred_store.get_entry(args.name, project_path=project_path)
|
|
5852
|
+
if entry and entry["scope"] == "global" and scope == "project":
|
|
5853
|
+
print(f"[error] '{args.name}' is a global credential -- "
|
|
5854
|
+
"re-run with --global to delete it.")
|
|
5855
|
+
return
|
|
5856
|
+
if cred_store.delete_credential(args.name, scope=scope, project_path=project_path):
|
|
5857
|
+
print(f"[OK] Removed credential '{args.name}' (scope={scope})")
|
|
5858
|
+
else:
|
|
5859
|
+
print(f"[error] no credential named '{args.name}' in {scope} scope")
|
|
5860
|
+
|
|
5861
|
+
|
|
5862
|
+
def _creds_cmd_import(args, project_path: str) -> None:
|
|
5863
|
+
from services import credential_store as cred_store
|
|
5864
|
+
|
|
5865
|
+
env_path = Path(getattr(args, "env_file", ""))
|
|
5866
|
+
if not env_path.exists():
|
|
5867
|
+
print(f"[error] file not found: {env_path}")
|
|
5868
|
+
return
|
|
5869
|
+
try:
|
|
5870
|
+
text = env_path.read_text(encoding="utf-8")
|
|
5871
|
+
result = cred_store.import_env(
|
|
5872
|
+
text, scope=_creds_scope(args), project_path=project_path,
|
|
5873
|
+
overwrite=bool(getattr(args, "overwrite", False)),
|
|
5874
|
+
)
|
|
5875
|
+
except (cred_store.CredentialError, RuntimeError) as exc:
|
|
5876
|
+
print(f"[error] {exc}")
|
|
5877
|
+
return
|
|
5878
|
+
print(f"[OK] Imported {len(result['created'])} credential(s): "
|
|
5879
|
+
f"{', '.join(result['created']) or '-'}")
|
|
5880
|
+
if result["skipped"]:
|
|
5881
|
+
print(f"Skipped {len(result['skipped'])}: {', '.join(result['skipped'])} "
|
|
5882
|
+
"(use --overwrite to replace)")
|
|
5883
|
+
|
|
5884
|
+
|
|
5741
5885
|
def cmd_jira(args):
|
|
5742
5886
|
"""Jira Cloud / Data Center credential + workspace management."""
|
|
5743
5887
|
sub = getattr(args, "jira_cmd", None)
|
|
@@ -7036,6 +7180,7 @@ def main():
|
|
|
7036
7180
|
"hub": cmd_hub,
|
|
7037
7181
|
"bitbucket": cmd_bitbucket,
|
|
7038
7182
|
"jira": cmd_jira,
|
|
7183
|
+
"creds": cmd_creds,
|
|
7039
7184
|
"oracle": cmd_oracle,
|
|
7040
7185
|
"upgrade": cmd_upgrade,
|
|
7041
7186
|
}
|
cli/commands/parser.py
CHANGED
|
@@ -401,6 +401,48 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
401
401
|
jr_default.add_argument("--name", default="", help="Account name (defaults to the default account)")
|
|
402
402
|
jr_default.add_argument("project_path", nargs="?", default=".")
|
|
403
403
|
|
|
404
|
+
# ── Credential vault (v2.58.0) ──────────────────────────────────────
|
|
405
|
+
p_creds = subparsers.add_parser(
|
|
406
|
+
"creds",
|
|
407
|
+
help="Credential vault — named secrets for agents (global + per-project)",
|
|
408
|
+
)
|
|
409
|
+
creds_subs = p_creds.add_subparsers(dest="creds_cmd")
|
|
410
|
+
|
|
411
|
+
cr_set = creds_subs.add_parser(
|
|
412
|
+
"set", help="Create or update a credential (value via hidden prompt)"
|
|
413
|
+
)
|
|
414
|
+
cr_set.add_argument("name", help="Entry name (env-var safe: [A-Za-z_][A-Za-z0-9_]*)")
|
|
415
|
+
cr_set.add_argument("--value", default="", help="Secret value (prompted via getpass if omitted — preferred)")
|
|
416
|
+
cr_set.add_argument("--stdin", action="store_true", help="Read the value from stdin (piped/multiline values)")
|
|
417
|
+
cr_set.add_argument("--type", dest="ctype", choices=["token", "env", "multiline"], default="token", help="Entry type")
|
|
418
|
+
cr_set.add_argument("--desc", default="", help="Human description shown in list/UI")
|
|
419
|
+
cr_set.add_argument("--env-var", default="", help="Env var name used at injection (default: entry name)")
|
|
420
|
+
cr_set.add_argument("--agent-readable", action="store_true", help="Allow the agent to reveal the decoded value into its context (default: injection-only)")
|
|
421
|
+
cr_set.add_argument("--inject", action="store_true", help="Auto-inject into every c3_shell run")
|
|
422
|
+
cr_set.add_argument("--global", dest="use_global", action="store_true", help="Store in the global scope (~/.c3) so every C3 project can use it")
|
|
423
|
+
# --path (not a trailing positional): a second positional after options
|
|
424
|
+
# breaks argparse on py<3.12 ("unrecognized arguments").
|
|
425
|
+
cr_set.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
|
|
426
|
+
|
|
427
|
+
cr_get = creds_subs.add_parser("get", help="Show entry metadata (masked; --show prints the value)")
|
|
428
|
+
cr_get.add_argument("name")
|
|
429
|
+
cr_get.add_argument("--show", action="store_true", help="Print the decoded value to the terminal")
|
|
430
|
+
cr_get.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
|
|
431
|
+
|
|
432
|
+
cr_list = creds_subs.add_parser("list", help="List credentials visible to this project")
|
|
433
|
+
cr_list.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
|
|
434
|
+
|
|
435
|
+
cr_rm = creds_subs.add_parser("rm", help="Delete a credential (value + registry entry)")
|
|
436
|
+
cr_rm.add_argument("name")
|
|
437
|
+
cr_rm.add_argument("--global", dest="use_global", action="store_true", help="Delete from the global scope")
|
|
438
|
+
cr_rm.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
|
|
439
|
+
|
|
440
|
+
cr_import = creds_subs.add_parser("import", help="Import KEY=VALUE lines from a .env file")
|
|
441
|
+
cr_import.add_argument("env_file", help="Path to the .env file")
|
|
442
|
+
cr_import.add_argument("--global", dest="use_global", action="store_true", help="Import into the global scope")
|
|
443
|
+
cr_import.add_argument("--overwrite", action="store_true", help="Replace entries already registered in the target scope")
|
|
444
|
+
cr_import.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
|
|
445
|
+
|
|
404
446
|
# ── Oracle Discovery API (v2.32.0) ──────────────────────────────────
|
|
405
447
|
p_oracle = subparsers.add_parser(
|
|
406
448
|
"oracle",
|
cli/guide/tools.html
CHANGED
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
<div class="sidebar-label">SCM</div>
|
|
126
126
|
<a href="#c3_task" class="sidebar-link"><span class="icon">✅</span> c3_task</a>
|
|
127
127
|
<a href="#c3_bitbucket" class="sidebar-link"><span class="icon">🪣</span> c3_bitbucket</a>
|
|
128
|
+
<a href="#c3_credentials" class="sidebar-link"><span class="icon">🔐</span> c3_credentials</a>
|
|
128
129
|
</div>
|
|
129
130
|
<div class="sidebar-section">
|
|
130
131
|
<div class="sidebar-label">Cross-project</div>
|
|
@@ -161,6 +162,7 @@
|
|
|
161
162
|
<a href="#c3_task" class="toc-item">c3_task <span class="cat">PM</span></a>
|
|
162
163
|
<a href="#c3_artifacts" class="toc-item">c3_artifacts <span class="cat">Config</span></a>
|
|
163
164
|
<a href="#c3_bitbucket" class="toc-item">c3_bitbucket <span class="cat">SCM</span></a>
|
|
165
|
+
<a href="#c3_credentials" class="toc-item">c3_credentials <span class="cat">Secrets</span></a>
|
|
164
166
|
<a href="#c3_project" class="toc-item">c3_project <span class="cat">Multi-project</span></a>
|
|
165
167
|
</div>
|
|
166
168
|
</div>
|
|
@@ -1239,6 +1241,45 @@ c3_bitbucket(action=<span class="str">'merge_pr'</span>, pr_id=<span class="num"
|
|
|
1239
1241
|
<p class="tool-desc"><strong>Audit trail:</strong> mutating actions (<code>merge_pr</code>, <code>create_branch</code>, <code>delete_branch</code>, webhook writes, etc.) are appended to the C3 edit ledger so the local audit log covers platform-side state changes too.</p>
|
|
1240
1242
|
</div>
|
|
1241
1243
|
</div>
|
|
1244
|
+
|
|
1245
|
+
<!-- c3_credentials -->
|
|
1246
|
+
<div class="tool-card" id="c3_credentials">
|
|
1247
|
+
<div class="tool-card-header">
|
|
1248
|
+
<span class="tool-name">c3_credentials</span>
|
|
1249
|
+
<div class="tag-row">
|
|
1250
|
+
<span class="badge badge-purple">secrets</span>
|
|
1251
|
+
<span class="badge">v2.58.0</span>
|
|
1252
|
+
</div>
|
|
1253
|
+
<span class="tool-tagline">Credential vault — the agent uses secrets by name, never by value</span>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div class="tool-card-body">
|
|
1256
|
+
<p class="tool-desc">A user-managed secret store, <strong>global</strong> (<code>~/.c3</code>, visible in every project) or <strong>per-project</strong> (<code>.c3</code>, shadows the same-named global). Values live in the OS keyring (service <code>c3-creds</code>; values over ~1KB in a Fernet-encrypted <code>.c3/secrets.enc</code> whose random master key is itself a keyring entry) — never in config files, and <em>never in the model's context</em>. <strong>Injection-first:</strong> the agent addresses secrets by name and C3 decodes them only at the subprocess boundary — <code>c3_shell(env_creds='A,B')</code> injects env vars, <code>{{cred:NAME}}</code> inside <code>cmd</code> expands server-side, and <code>inject</code>-flagged entries auto-inject into every shell run; echoed values are redacted back to <code>[cred:NAME]</code>. Resolution is <strong>realm-atomic</strong>: a project-registered name resolves in the project realm or not at all, so a hostile repo's committed config can never siphon your global values (tested).</p>
|
|
1257
|
+
|
|
1258
|
+
<h4>Actions</h4>
|
|
1259
|
+
<table class="params-table">
|
|
1260
|
+
<thead><tr><th>Action</th><th>Group</th><th>Description</th></tr></thead>
|
|
1261
|
+
<tbody>
|
|
1262
|
+
<tr><td class="param-name">list</td><td>Read</td><td class="param-desc">Merged registry (project shadows global) — names, scope, type, flags, usage. Never values</td></tr>
|
|
1263
|
+
<tr><td class="param-name">describe</td><td>Read</td><td class="param-desc">Metadata + storage + live fingerprint (first 8 hex of SHA-256, computed on demand, never persisted)</td></tr>
|
|
1264
|
+
<tr><td class="param-name">check</td><td>Read</td><td class="param-desc">Resolvability probe — does the stored value decode?</td></tr>
|
|
1265
|
+
<tr><td class="param-name">set</td><td>Write</td><td class="param-desc">Create/replace an entry. <code>agent_readable</code> may be set at creation only — raising it on an existing entry is user-only</td></tr>
|
|
1266
|
+
<tr><td class="param-name">delete</td><td>Write</td><td class="param-desc">Remove value + registry entry (owning scope inferred when omitted)</td></tr>
|
|
1267
|
+
<tr><td class="param-name">reveal</td><td>Gated</td><td class="param-desc">The only value-returning action — refused unless the user enabled <code>agent_readable</code> on that entry</td></tr>
|
|
1268
|
+
</tbody>
|
|
1269
|
+
</table>
|
|
1270
|
+
|
|
1271
|
+
<h4>Examples</h4>
|
|
1272
|
+
<pre><code><span class="com"># What can I use? (names and metadata only)</span>
|
|
1273
|
+
c3_credentials(action=<span class="str">'list'</span>)
|
|
1274
|
+
c3_credentials(action=<span class="str">'check'</span>, name=<span class="str">'NPM_TOKEN'</span>)
|
|
1275
|
+
|
|
1276
|
+
<span class="com"># USE a secret without ever seeing it</span>
|
|
1277
|
+
c3_shell(cmd=<span class="str">'npm publish'</span>, env_creds=<span class="str">'NPM_TOKEN'</span>)
|
|
1278
|
+
c3_shell(cmd=<span class="str">'curl -H "Authorization: Bearer {{cred:API_KEY}}" https://api.example.com'</span>)</code></pre>
|
|
1279
|
+
|
|
1280
|
+
<p class="tool-desc"><strong>Surfaces:</strong> the <code>c3 creds</code> CLI (<code>set</code> / <code>get</code> / <code>list</code> / <code>rm</code> / <code>import .env</code>, <code>--global</code> for the shared scope), a per-project dashboard <strong>Credentials</strong> tab, and — since v2.59.0 — a top-level <strong>Credentials</strong> 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 (<code>c3_project</code>) shells run with credentials disabled.</p>
|
|
1281
|
+
</div>
|
|
1282
|
+
</div>
|
|
1242
1283
|
</div>
|
|
1243
1284
|
|
|
1244
1285
|
<!-- ═══════════════════════════════════════ CROSS-PROJECT ═════ -->
|
cli/hub_server.py
CHANGED
|
@@ -333,6 +333,7 @@ _HUB_JS_FILES = [
|
|
|
333
333
|
"hub_ui/components/session_drawer.js",
|
|
334
334
|
"hub_ui/components/drill_panel.js",
|
|
335
335
|
"hub_ui/components/drill_views.js",
|
|
336
|
+
"hub_ui/components/hub_credentials.js",
|
|
336
337
|
"hub_ui/components/drill_subprojects.js",
|
|
337
338
|
"hub_ui/components/drill_health.js",
|
|
338
339
|
"hub_ui/components/drill_tasks.js",
|
|
@@ -437,8 +438,8 @@ def api_hub_config_set():
|
|
|
437
438
|
cfg["projects_view"] = projects_view
|
|
438
439
|
if "main_view" in data:
|
|
439
440
|
main_view = str(data["main_view"]).strip().lower()
|
|
440
|
-
if main_view not in {"projects", "board"}:
|
|
441
|
-
return jsonify({"error": "main_view must be 'projects' or '
|
|
441
|
+
if main_view not in {"projects", "board", "creds"}:
|
|
442
|
+
return jsonify({"error": "main_view must be 'projects', 'board' or 'creds'"}), 400
|
|
442
443
|
cfg["main_view"] = main_view
|
|
443
444
|
if "oracle_url" in data:
|
|
444
445
|
cfg["oracle_url"] = str(data["oracle_url"]).strip()
|
|
@@ -1773,6 +1774,276 @@ def api_projects_config_get():
|
|
|
1773
1774
|
"defaults": {k: _config_defaults(k) for k in _CONFIG_READ_SECTIONS}})
|
|
1774
1775
|
|
|
1775
1776
|
|
|
1777
|
+
# ── Credentials (hub) ────────────────────────────────────────────────────────
|
|
1778
|
+
# Write-only wire contract, hub edition: values go IN over POST and are never
|
|
1779
|
+
# returned by any hub route (no reveal exists here). `credentials` stays out of
|
|
1780
|
+
# _CONFIG_WRITE_SECTIONS — these dedicated routes are the only hub write path.
|
|
1781
|
+
|
|
1782
|
+
_CRED_PUBLIC_FIELDS = ("scope", "type", "value_len", "env_var", "inject",
|
|
1783
|
+
"agent_readable", "description", "storage", "created",
|
|
1784
|
+
"updated")
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _cred_entry_public(name, entry, *, usage=None, shadows_global=None):
|
|
1788
|
+
"""Explicit allowlist serializer — structurally cannot emit a value."""
|
|
1789
|
+
rec = {"name": name}
|
|
1790
|
+
for key in _CRED_PUBLIC_FIELDS:
|
|
1791
|
+
rec[key] = entry.get(key, "")
|
|
1792
|
+
rec["value_len"] = entry.get("value_len", 0)
|
|
1793
|
+
rec["inject"] = bool(entry.get("inject"))
|
|
1794
|
+
rec["agent_readable"] = bool(entry.get("agent_readable"))
|
|
1795
|
+
if usage is not None:
|
|
1796
|
+
rec["last_used"] = (usage.get(name) or {}).get("last_used", "")
|
|
1797
|
+
rec["use_count"] = (usage.get(name) or {}).get("use_count", 0)
|
|
1798
|
+
if shadows_global is not None:
|
|
1799
|
+
rec["shadows_global"] = bool(shadows_global)
|
|
1800
|
+
return rec
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _resolve_cred_target(path: str, scope: str, *, mutation: bool):
|
|
1804
|
+
"""Resolve a credentials request target to (project, store_path, error).
|
|
1805
|
+
|
|
1806
|
+
scope='global' with no path targets the shared vault (~/.c3) directly;
|
|
1807
|
+
project scope requires a registered path. Mutations on a path without a
|
|
1808
|
+
.c3/ dir get 409 needs_init so the hub can't scatter .c3 dirs around."""
|
|
1809
|
+
from services import credential_store as cred_store
|
|
1810
|
+
if scope not in ("project", "global"):
|
|
1811
|
+
return None, "", (jsonify({"error": "scope must be 'project' or 'global'"}), 400)
|
|
1812
|
+
if not path:
|
|
1813
|
+
if scope == "project":
|
|
1814
|
+
return None, "", (jsonify({"error": "path is required for project scope"}), 400)
|
|
1815
|
+
home = cred_store.global_base()
|
|
1816
|
+
if home is None:
|
|
1817
|
+
return None, "", (jsonify({"error": "global scope unresolvable (no home dir)"}), 500)
|
|
1818
|
+
return None, str(home), None
|
|
1819
|
+
try:
|
|
1820
|
+
resolved = _resolve_project_path(path)
|
|
1821
|
+
except ValueError as e:
|
|
1822
|
+
return None, "", (jsonify({"error": str(e)}), 404)
|
|
1823
|
+
if mutation and not (resolved / ".c3").is_dir():
|
|
1824
|
+
return None, "", (jsonify({"error": "not initialized", "needs_init": True}), 409)
|
|
1825
|
+
return resolved, str(resolved), None
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _hub_cred_audit(action: str, name: str, scope: str, project) -> None:
|
|
1829
|
+
"""Names only — never values. Failure-safe. Project mutations audit to the
|
|
1830
|
+
target project's ActivityLog + EditLedger; global-scope mutations also land
|
|
1831
|
+
in ~/.c3/activity_log.jsonl so the shared vault keeps its own trail."""
|
|
1832
|
+
if project is not None:
|
|
1833
|
+
try:
|
|
1834
|
+
from services.activity_log import ActivityLog
|
|
1835
|
+
ActivityLog(str(project)).log("cred_action", {
|
|
1836
|
+
"kind": "creds", "action": action, "name": name,
|
|
1837
|
+
"scope": scope, "via": "hub",
|
|
1838
|
+
})
|
|
1839
|
+
except Exception:
|
|
1840
|
+
pass
|
|
1841
|
+
try:
|
|
1842
|
+
from services.edit_ledger import EditLedger
|
|
1843
|
+
EditLedger(str(project)).log_edit(
|
|
1844
|
+
file=f"cred://{name}", change_type=f"cred_{action}",
|
|
1845
|
+
summary=f"{action} {name} ({scope}) via Hub",
|
|
1846
|
+
tags=["creds", action],
|
|
1847
|
+
detail={"kind": "creds", "action": action, "name": name,
|
|
1848
|
+
"scope": scope},
|
|
1849
|
+
)
|
|
1850
|
+
except Exception:
|
|
1851
|
+
pass
|
|
1852
|
+
if scope == "global" or project is None:
|
|
1853
|
+
try:
|
|
1854
|
+
from services import credential_store as cred_store
|
|
1855
|
+
from services.activity_log import ActivityLog
|
|
1856
|
+
home = cred_store.global_base()
|
|
1857
|
+
if home is not None:
|
|
1858
|
+
ActivityLog(str(home)).log("cred_action", {
|
|
1859
|
+
"kind": "creds", "action": action, "name": name,
|
|
1860
|
+
"scope": scope, "via": "hub",
|
|
1861
|
+
})
|
|
1862
|
+
except Exception:
|
|
1863
|
+
pass
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
@app.route("/api/projects/credentials", methods=["GET"])
|
|
1867
|
+
def api_projects_credentials():
|
|
1868
|
+
"""Masked credential registry for a project (global entries + project
|
|
1869
|
+
shadows). Values never transit the hub outbound — the allowlist serializer
|
|
1870
|
+
returns metadata, usage and shadow info only."""
|
|
1871
|
+
path = (request.args.get("path") or "").strip()
|
|
1872
|
+
if not path:
|
|
1873
|
+
return jsonify({"error": "path is required"}), 400
|
|
1874
|
+
try:
|
|
1875
|
+
resolved = _resolve_project_path(path)
|
|
1876
|
+
except ValueError as e:
|
|
1877
|
+
return jsonify({"error": str(e)}), 404
|
|
1878
|
+
from services import credential_store as cred_store
|
|
1879
|
+
usage = cred_store.read_usage_state(str(resolved))
|
|
1880
|
+
home = cred_store.global_base()
|
|
1881
|
+
global_names = set(cred_store.list_entries(str(home))) if home else set()
|
|
1882
|
+
entries = []
|
|
1883
|
+
for name, entry in cred_store.list_entries(str(resolved)).items():
|
|
1884
|
+
entries.append(_cred_entry_public(
|
|
1885
|
+
name, entry, usage=usage,
|
|
1886
|
+
shadows_global=(entry.get("scope") == "project"
|
|
1887
|
+
and name in global_names)))
|
|
1888
|
+
return jsonify({"path": str(resolved), "entries": entries})
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
@app.route("/api/projects/credentials", methods=["POST"])
|
|
1892
|
+
def api_projects_credentials_set():
|
|
1893
|
+
"""Create/update an entry from the hub. `value` optional — metadata-only
|
|
1894
|
+
update when absent, touching ONLY the keys present in the payload; a
|
|
1895
|
+
submitted value is stored and never echoed back. scope='global' with no
|
|
1896
|
+
`path` targets the shared vault directly."""
|
|
1897
|
+
from services import credential_store as cred_store
|
|
1898
|
+
data = request.get_json(force=True) or {}
|
|
1899
|
+
name = str(data.get("name") or "").strip()
|
|
1900
|
+
scope = str(data.get("scope") or "project").strip().lower()
|
|
1901
|
+
project, store_path, err = _resolve_cred_target(
|
|
1902
|
+
str(data.get("path") or "").strip(), scope, mutation=True)
|
|
1903
|
+
if err:
|
|
1904
|
+
return err
|
|
1905
|
+
value = str(data.get("value") or "")
|
|
1906
|
+
ctype = str(data.get("type") or data.get("ctype") or "token")
|
|
1907
|
+
try:
|
|
1908
|
+
if value:
|
|
1909
|
+
entry = cred_store.set_credential(
|
|
1910
|
+
name, value, scope=scope, project_path=store_path, ctype=ctype,
|
|
1911
|
+
description=str(data.get("description") or ""),
|
|
1912
|
+
env_var=str(data.get("env_var") or ""),
|
|
1913
|
+
agent_readable=bool(data.get("agent_readable")),
|
|
1914
|
+
inject=bool(data.get("inject")))
|
|
1915
|
+
else:
|
|
1916
|
+
# Metadata-only update: touch ONLY the keys present in the payload
|
|
1917
|
+
# so a single-field toggle can't clobber the others.
|
|
1918
|
+
fields = {}
|
|
1919
|
+
for key in ("description", "env_var"):
|
|
1920
|
+
if key in data:
|
|
1921
|
+
fields[key] = str(data[key] or "")
|
|
1922
|
+
for key in ("agent_readable", "inject"):
|
|
1923
|
+
if key in data:
|
|
1924
|
+
fields[key] = bool(data[key])
|
|
1925
|
+
if "type" in data or "ctype" in data:
|
|
1926
|
+
fields["type"] = ctype
|
|
1927
|
+
entry = cred_store.update_metadata(
|
|
1928
|
+
name, scope=scope, project_path=store_path, **fields)
|
|
1929
|
+
except cred_store.CredentialError as exc:
|
|
1930
|
+
return jsonify({"error": str(exc)}), 400
|
|
1931
|
+
except RuntimeError as exc:
|
|
1932
|
+
return jsonify({"error": str(exc)}), 500
|
|
1933
|
+
_hub_cred_audit("set" if value else "update", name, scope, project)
|
|
1934
|
+
return jsonify({"entry": _cred_entry_public(name, {**entry, "scope": scope})})
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
@app.route("/api/projects/credentials/import", methods=["POST"])
|
|
1938
|
+
def api_projects_credentials_import():
|
|
1939
|
+
"""Import KEY=VALUE lines (.env paste). Values are stored, never echoed."""
|
|
1940
|
+
from services import credential_store as cred_store
|
|
1941
|
+
data = request.get_json(force=True) or {}
|
|
1942
|
+
scope = str(data.get("scope") or "project").strip().lower()
|
|
1943
|
+
project, store_path, err = _resolve_cred_target(
|
|
1944
|
+
str(data.get("path") or "").strip(), scope, mutation=True)
|
|
1945
|
+
if err:
|
|
1946
|
+
return err
|
|
1947
|
+
try:
|
|
1948
|
+
result = cred_store.import_env(
|
|
1949
|
+
str(data.get("text") or ""), scope=scope, project_path=store_path,
|
|
1950
|
+
overwrite=bool(data.get("overwrite")))
|
|
1951
|
+
except cred_store.CredentialError as exc:
|
|
1952
|
+
return jsonify({"error": str(exc)}), 400
|
|
1953
|
+
except RuntimeError as exc:
|
|
1954
|
+
return jsonify({"error": str(exc)}), 500
|
|
1955
|
+
for created in result["created"]:
|
|
1956
|
+
_hub_cred_audit("set", created, scope, project)
|
|
1957
|
+
return jsonify(result)
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
@app.route("/api/projects/credentials/<name>", methods=["DELETE"])
|
|
1961
|
+
def api_projects_credentials_delete(name):
|
|
1962
|
+
"""Delete an entry (value + registry). Scope inferred from the owning
|
|
1963
|
+
realm when omitted."""
|
|
1964
|
+
from services import credential_store as cred_store
|
|
1965
|
+
scope = str(request.args.get("scope") or "").strip().lower()
|
|
1966
|
+
path = str(request.args.get("path") or "").strip()
|
|
1967
|
+
project, store_path, err = _resolve_cred_target(
|
|
1968
|
+
path, scope or ("project" if path else "global"), mutation=True)
|
|
1969
|
+
if err:
|
|
1970
|
+
return err
|
|
1971
|
+
if not scope:
|
|
1972
|
+
entry = cred_store.get_entry(name, project_path=store_path)
|
|
1973
|
+
scope = (entry.get("scope") or "project") if entry else \
|
|
1974
|
+
("project" if path else "global")
|
|
1975
|
+
try:
|
|
1976
|
+
removed = cred_store.delete_credential(
|
|
1977
|
+
name, scope=scope, project_path=store_path)
|
|
1978
|
+
except cred_store.CredentialError as exc:
|
|
1979
|
+
return jsonify({"error": str(exc)}), 400
|
|
1980
|
+
if removed:
|
|
1981
|
+
_hub_cred_audit("delete", name, scope, project)
|
|
1982
|
+
return jsonify({"removed": bool(removed), "scope": scope})
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
@app.route("/api/projects/credentials/<name>/check", methods=["POST"])
|
|
1986
|
+
def api_projects_credentials_check(name):
|
|
1987
|
+
"""Resolvability probe — returns a fingerprint, never the value."""
|
|
1988
|
+
from services import credential_store as cred_store
|
|
1989
|
+
data = request.get_json(silent=True) or {}
|
|
1990
|
+
path = str(data.get("path") or "").strip()
|
|
1991
|
+
project, store_path, err = _resolve_cred_target(
|
|
1992
|
+
path, "project" if path else "global", mutation=False)
|
|
1993
|
+
if err:
|
|
1994
|
+
return err
|
|
1995
|
+
entry = cred_store.get_entry(name, project_path=store_path)
|
|
1996
|
+
if not entry:
|
|
1997
|
+
return jsonify({"error": f"no credential named '{name}'"}), 404
|
|
1998
|
+
return jsonify({
|
|
1999
|
+
"name": name,
|
|
2000
|
+
"scope": entry["scope"],
|
|
2001
|
+
"storage": entry.get("storage", "keyring"),
|
|
2002
|
+
"resolvable": cred_store.get_value(name, project_path=store_path) is not None,
|
|
2003
|
+
"fingerprint": cred_store.fingerprint(name, project_path=store_path),
|
|
2004
|
+
})
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
@app.route("/api/hub/credentials/overview", methods=["GET"])
|
|
2008
|
+
def api_hub_credentials_overview():
|
|
2009
|
+
"""Cross-project credential inventory: the global vault plus each
|
|
2010
|
+
registered project's project-scoped entries, with shadow info both ways.
|
|
2011
|
+
Metadata only — the allowlist serializer structurally cannot emit a value."""
|
|
2012
|
+
from services import credential_store as cred_store
|
|
2013
|
+
home = cred_store.global_base()
|
|
2014
|
+
global_entries = cred_store.list_entries(str(home)) if home else {}
|
|
2015
|
+
shadowed_in = {name: [] for name in global_entries}
|
|
2016
|
+
projects_out = []
|
|
2017
|
+
for p in _pm().list_projects():
|
|
2018
|
+
ppath = str(p.get("path") or "")
|
|
2019
|
+
row = {"name": p.get("name") or "", "path": ppath,
|
|
2020
|
+
"initialized": True, "error": None, "entries": []}
|
|
2021
|
+
try:
|
|
2022
|
+
if not (Path(ppath) / ".c3").is_dir():
|
|
2023
|
+
row["initialized"] = False
|
|
2024
|
+
else:
|
|
2025
|
+
usage = cred_store.read_usage_state(ppath)
|
|
2026
|
+
for name, entry in cred_store.list_entries(ppath).items():
|
|
2027
|
+
if entry.get("scope") != "project":
|
|
2028
|
+
continue
|
|
2029
|
+
row["entries"].append(_cred_entry_public(
|
|
2030
|
+
name, entry, usage=usage,
|
|
2031
|
+
shadows_global=name in global_entries))
|
|
2032
|
+
if name in shadowed_in:
|
|
2033
|
+
shadowed_in[name].append(
|
|
2034
|
+
{"name": row["name"], "path": ppath})
|
|
2035
|
+
except Exception as e: # per-row isolation, like /api/search/global
|
|
2036
|
+
row["error"] = str(e)
|
|
2037
|
+
projects_out.append(row)
|
|
2038
|
+
global_usage = cred_store.read_usage_state(str(home)) if home else {}
|
|
2039
|
+
global_out = [
|
|
2040
|
+
{**_cred_entry_public(name, entry, usage=global_usage),
|
|
2041
|
+
"shadowed_in": shadowed_in.get(name, [])}
|
|
2042
|
+
for name, entry in global_entries.items()
|
|
2043
|
+
]
|
|
2044
|
+
return jsonify({"global": {"entries": global_out}, "projects": projects_out})
|
|
2045
|
+
|
|
2046
|
+
|
|
1776
2047
|
@app.route("/api/projects/config", methods=["PUT"])
|
|
1777
2048
|
def api_projects_config_put():
|
|
1778
2049
|
"""Whitelisted section write: deep-merge, atomic replace, audited on the target."""
|
cli/hub_ui/app.js
CHANGED
|
@@ -17,7 +17,7 @@ function App() {
|
|
|
17
17
|
const [version, setVersion] = useState('');
|
|
18
18
|
const [projects, setProjects] = useState([]);
|
|
19
19
|
const [loaded, setLoaded] = useState(false);
|
|
20
|
-
const [mainView, setMainView] = useState('projects'); // projects | board
|
|
20
|
+
const [mainView, setMainView] = useState('projects'); // projects | board | creds
|
|
21
21
|
const [filter, setFilter] = useState('all'); // all | active | idle | tag:<x>
|
|
22
22
|
const [search, setSearch] = useState('');
|
|
23
23
|
const [view, setView] = useState('list'); // list | grid
|
|
@@ -44,7 +44,7 @@ function App() {
|
|
|
44
44
|
if (cfg.projects_view === 'grid') setView('grid');
|
|
45
45
|
if (cfg.sidebar_collapsed != null) setSidebarCollapsed(!!cfg.sidebar_collapsed);
|
|
46
46
|
if (cfg.sidebar_group) setFilter(cfg.sidebar_group);
|
|
47
|
-
if (cfg.main_view === 'board') setMainView(
|
|
47
|
+
if (cfg.main_view === 'board' || cfg.main_view === 'creds') setMainView(cfg.main_view);
|
|
48
48
|
} catch { }
|
|
49
49
|
try { const v = await api.get('/api/version'); setVersion(v.c3_version || ''); } catch { }
|
|
50
50
|
}, []);
|
|
@@ -112,6 +112,8 @@ function App() {
|
|
|
112
112
|
}}>
|
|
113
113
|
{mainView === 'board' ? (
|
|
114
114
|
<TaskBoard projects={projects} onOpenDrill={openDrill} />
|
|
115
|
+
) : mainView === 'creds' ? (
|
|
116
|
+
<HubCredentials projects={projects} onOpenDrill={openDrill} />
|
|
115
117
|
) : (
|
|
116
118
|
<React.Fragment>
|
|
117
119
|
<SummaryBar projects={projects} search={search} setSearch={setSearch}
|
|
@@ -13,6 +13,7 @@ const DRILL_PANEL_TABS = [
|
|
|
13
13
|
['sessions', 'Sessions'],
|
|
14
14
|
['health', 'Health'],
|
|
15
15
|
['budget', 'Budget'],
|
|
16
|
+
['creds', 'Credentials'],
|
|
16
17
|
['config', 'Config'],
|
|
17
18
|
['mcp', 'MCP'],
|
|
18
19
|
];
|
|
@@ -132,6 +133,7 @@ function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal, pro
|
|
|
132
133
|
case 'sessions': return <DrillSessions project={project} />;
|
|
133
134
|
case 'health': return <DrillHealth project={project} onChanged={onChanged} />;
|
|
134
135
|
case 'budget': return <DrillBudget project={project} />;
|
|
136
|
+
case 'creds': return <DrillCredentials project={project} />;
|
|
135
137
|
case 'config': return <ConfigEditor project={project} />;
|
|
136
138
|
case 'mcp': return <McpManager project={project} onChanged={onChanged} />;
|
|
137
139
|
default: return <DrillOverview project={project} onChanged={onChanged} setTab={setTab} />;
|
|
@@ -282,7 +282,13 @@ function DrillLedger({ project }) {
|
|
|
282
282
|
);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
// ──
|
|
285
|
+
// ── Credentials ────────────────────────────────────────────────
|
|
286
|
+
// Full management via the shared CredsManager (hub_credentials.js).
|
|
287
|
+
// Write-only wire: values are submitted inbound-only and never returned.
|
|
288
|
+
function DrillCredentials({ project }) {
|
|
289
|
+
return <CredsManager path={project.path} projectName={project.name} />;
|
|
290
|
+
}
|
|
291
|
+
|
|
286
292
|
function DrillSessions({ project }) {
|
|
287
293
|
const [data, setData] = useState(null);
|
|
288
294
|
const [needsInit, setNeedsInit] = useState(false);
|