code-context-control 2.57.1__py3-none-any.whl → 2.58.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 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.57.1"
95
+ __version__ = "2.58.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/hub_server.py CHANGED
@@ -1773,6 +1773,36 @@ def api_projects_config_get():
1773
1773
  "defaults": {k: _config_defaults(k) for k in _CONFIG_READ_SECTIONS}})
1774
1774
 
1775
1775
 
1776
+ @app.route("/api/projects/credentials", methods=["GET"])
1777
+ def api_projects_credentials():
1778
+ """Read-only masked credential registry for a project (global entries +
1779
+ project shadows). Values never transit the hub — the explicit field
1780
+ allowlist below returns metadata only; management lives in the project UI
1781
+ (`credentials` is deliberately absent from _CONFIG_WRITE_SECTIONS)."""
1782
+ path = (request.args.get("path") or "").strip()
1783
+ if not path:
1784
+ return jsonify({"error": "path is required"}), 400
1785
+ try:
1786
+ resolved = _resolve_project_path(path)
1787
+ except ValueError as e:
1788
+ return jsonify({"error": str(e)}), 404
1789
+ from services import credential_store as cred_store
1790
+ entries = []
1791
+ for name, entry in cred_store.list_entries(str(resolved)).items():
1792
+ entries.append({
1793
+ "name": name,
1794
+ "scope": entry.get("scope", ""),
1795
+ "type": entry.get("type", "token"),
1796
+ "value_len": entry.get("value_len", 0),
1797
+ "env_var": entry.get("env_var", ""),
1798
+ "inject": bool(entry.get("inject")),
1799
+ "agent_readable": bool(entry.get("agent_readable")),
1800
+ "description": entry.get("description", ""),
1801
+ "updated": entry.get("updated", ""),
1802
+ })
1803
+ return jsonify({"path": str(resolved), "entries": entries})
1804
+
1805
+
1776
1806
  @app.route("/api/projects/config", methods=["PUT"])
1777
1807
  def api_projects_config_put():
1778
1808
  """Whitelisted section write: deep-merge, atomic replace, audited on the target."""
@@ -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} />;
@@ -283,6 +283,56 @@ function DrillLedger({ project }) {
283
283
  }
284
284
 
285
285
  // ── Sessions ───────────────────────────────────────────────────
286
+ // Read-only: the hub never transits credential values — this lists metadata
287
+ // from GET /api/projects/credentials; management lives in the project UI.
288
+ function DrillCredentials({ project }) {
289
+ const [data, setData] = useState(null);
290
+ const [err, setErr] = useState(null);
291
+
292
+ const load = async () => {
293
+ setErr(null);
294
+ try {
295
+ const d = await api.get('/api/projects/credentials?path=' + encodeURIComponent(project.path));
296
+ setData(d);
297
+ } catch (e) { setErr(e.message); }
298
+ };
299
+ useEffect(() => { setData(null); load(); }, [project.path]);
300
+
301
+ if (err) return <DrillMsg text={'Failed to load credentials: ' + err} color={T.error} />;
302
+ if (!data) return <DrillMsg text="Loading credentials…" />;
303
+
304
+ const entries = data.entries || [];
305
+ return (
306
+ <div className="fade-up">
307
+ <div style={{ fontSize: 11, color: T.textMuted, lineHeight: 1.5, marginBottom: 12 }}>
308
+ Read-only view — values never transit the hub. Manage entries (and the
309
+ agent_readable / inject flags) in this project's own UI Credentials tab
310
+ or via <span className="mono">c3 creds</span>.
311
+ </div>
312
+ {entries.length === 0 ? (
313
+ <DrillMsg text="No credentials registered for this project." />
314
+ ) : entries.map((entry) => (
315
+ <div key={entry.scope + '|' + entry.name} style={{
316
+ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0',
317
+ borderBottom: `1px solid ${T.border}`, fontSize: 12,
318
+ }}>
319
+ <I name="lock" size={12} color={T.textMuted} />
320
+ <span className="mono" style={{ fontWeight: 600, color: T.text }}>{entry.name}</span>
321
+ <Badge color={entry.scope === 'global' ? T.accent : T.ok}>{entry.scope}</Badge>
322
+ <Badge color={T.textMuted}>{entry.type}</Badge>
323
+ <span className="mono" style={{ color: T.textDim }}>len={entry.value_len}</span>
324
+ {!!entry.inject && <Badge color={T.warn}>inject</Badge>}
325
+ {!!entry.agent_readable && <Badge color={T.error}>agent_readable</Badge>}
326
+ <span style={{
327
+ flex: 1, color: T.textMuted, fontSize: 11, textAlign: 'right',
328
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
329
+ }}>{entry.description}</span>
330
+ </div>
331
+ ))}
332
+ </div>
333
+ );
334
+ }
335
+
286
336
  function DrillSessions({ project }) {
287
337
  const [data, setData] = useState(null);
288
338
  const [needsInit, setNeedsInit] = useState(false);
cli/mcp_server.py CHANGED
@@ -355,6 +355,22 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
355
355
  snap_pct = 0
356
356
  svc = _svc(ctx)
357
357
 
358
+ # Credential hygiene: scrub any decoded vault value from every PERSISTED
359
+ # copy (session store, activity log, auto-memory). The response returned
360
+ # to the model is deliberately untouched — reveal is gated upstream by
361
+ # the per-entry agent_readable flag, and injected values only appear in
362
+ # output when a subprocess echoed them (already scrubbed in c3_shell;
363
+ # this is the belt-and-braces layer for every other tool).
364
+ persisted_response = response
365
+ try:
366
+ from services import credential_store as _creds
367
+ if _creds._ACTIVE_SECRETS:
368
+ args = _creds.redact_obj(args)
369
+ summary = _creds.redact_text(summary)
370
+ persisted_response = _creds.redact_text(response)
371
+ except Exception:
372
+ pass
373
+
358
374
  # Minimal critical section: only the module-global timing state needs
359
375
  # exclusive access. Disk I/O happens outside the lock so concurrent tool
360
376
  # calls don't serialize on append-only JSONL writes.
@@ -380,7 +396,7 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
380
396
  tracker.ping("tool") # throttled heartbeat; idle gaps close sessions
381
397
  except Exception:
382
398
  pass
383
- svc.session_mgr.track_response(tool_name, response, response_tokens=response_tokens)
399
+ svc.session_mgr.track_response(tool_name, persisted_response, response_tokens=response_tokens)
384
400
 
385
401
  hybrid_cfg = svc.hybrid_config or {}
386
402
 
@@ -399,7 +415,7 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
399
415
  # --- Outside lock: auto-memory extraction (may do file I/O / Ollama) ---
400
416
  if hasattr(svc, "auto_memory"):
401
417
  try:
402
- svc.auto_memory.on_tool_complete(tool_name, args, summary, response)
418
+ svc.auto_memory.on_tool_complete(tool_name, args, summary, persisted_response)
403
419
  except Exception:
404
420
  pass
405
421
 
@@ -422,7 +438,7 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
422
438
  return response
423
439
 
424
440
 
425
- # ─── TOOL REGISTRATIONS (18 tools) ────────────────────────────────
441
+ # ─── TOOL REGISTRATIONS (19 tools) ────────────────────────────────
426
442
  # Each tool's first docstring line should state WHEN to reach for it —
427
443
  # that's what Claude reads when selecting between tools.
428
444
 
@@ -711,10 +727,14 @@ async def c3_impact(target: str, file_path: str = "", mode: str = "symbol",
711
727
  @mcp.tool()
712
728
  async def c3_shell(cmd: str, cwd: str = "", timeout: int = 60,
713
729
  filter_output: bool = True, log: bool = True,
730
+ env_creds: str = "",
714
731
  ctx: Context = None) -> str:
715
732
  """EXECUTE shell command — structured returns, auto-filter, ledger-aware.
716
733
  Use for tests, git, build, scripts. Returns exit_code/stdout/stderr/duration_ms.
717
734
  Auto-filters stdout >30 lines; auto-logs git mutations to the edit ledger.
735
+ Credentials: env_creds='NAME1,NAME2' injects vault entries as env vars, and
736
+ {{cred:NAME}} inside cmd expands server-side — decoded values never enter
737
+ model context (see c3_credentials; echoed values are auto-redacted).
718
738
  Best-effort block of catastrophic commands (rm -rf of /, a top-level system dir, or
719
739
  $HOME/~; fork bombs; whole-drive wipes) — a guard, NOT a sandbox. Soft-warns on
720
740
  --force, --no-verify, reset --hard.
@@ -725,7 +745,8 @@ async def c3_shell(cmd: str, cwd: str = "", timeout: int = 60,
725
745
  def finalize(name, args, resp, summ, **kw):
726
746
  return _finalize_response(ctx, name, args, resp, summ, **kw)
727
747
 
728
- return await handle_shell(cmd, cwd, timeout, filter_output, log, svc, finalize)
748
+ return await handle_shell(cmd, cwd, timeout, filter_output, log, svc, finalize,
749
+ env_creds=env_creds)
729
750
 
730
751
 
731
752
  @mcp.tool()
@@ -822,6 +843,40 @@ async def c3_jira(
822
843
  )
823
844
 
824
845
 
846
+ @mcp.tool()
847
+ async def c3_credentials(
848
+ action: str,
849
+ name: str = "",
850
+ value: str = "",
851
+ scope: str = "",
852
+ description: str = "",
853
+ ctype: str = "",
854
+ env_var: str = "",
855
+ inject: bool = False,
856
+ agent_readable: bool = False,
857
+ ctx: Context = None,
858
+ ) -> str:
859
+ """CREDENTIAL VAULT — named secrets the user manages (global + per-project), injection-first.
860
+ actions: list, describe (name), check (name), reveal (name — only entries the
861
+ user marked agent_readable), set (name, value [scope=project|global] [ctype=token|env|multiline]
862
+ [description] [env_var] [inject]), delete (name [scope]).
863
+ To USE a credential, do NOT reveal it — pass env_creds='NAME1,NAME2' to c3_shell
864
+ (injected as env vars) or write {{cred:NAME}} inside the cmd (expanded server-side);
865
+ the decoded value never enters model context. Values live in the OS keyring /
866
+ an encrypted sidecar, never in config files. Mutations and reveals are ledger-logged."""
867
+ svc = _svc(ctx)
868
+
869
+ def finalize(fname, fargs, fresp, fsumm, **kw):
870
+ return _finalize_response(ctx, fname, fargs, fresp, fsumm, **kw)
871
+
872
+ from cli.tools.credentials import handle_credentials
873
+ return await asyncio.to_thread(
874
+ handle_credentials, action, svc, finalize,
875
+ name=name, value=value, scope=scope, description=description,
876
+ ctype=ctype, env_var=env_var, inject=inject, agent_readable=agent_readable,
877
+ )
878
+
879
+
825
880
  @mcp.tool()
826
881
  async def c3_project(
827
882
  action: str,
cli/server.py CHANGED
@@ -200,6 +200,7 @@ _UI_JS_FILES = [
200
200
  "ui/components/edits.js",
201
201
  "ui/components/bitbucket.js",
202
202
  "ui/components/jira.js",
203
+ "ui/components/credentials.js",
203
204
  "ui/components/instructions.js",
204
205
  "ui/components/settings.js",
205
206
  "ui/components/chat.js",
@@ -2638,6 +2639,152 @@ def api_memory_llm_key_set():
2638
2639
  return jsonify({"api_key_set": True})
2639
2640
 
2640
2641
 
2642
+ # ── Credential vault (v2.58.0) ──────────────────────────
2643
+ # Write-only wire contract: no endpoint ever returns a stored value.
2644
+ # Values go IN over POST and come out only at the subprocess boundary
2645
+ # (cli/tools/shell.py). GETs return masked metadata + live fingerprint.
2646
+
2647
+
2648
+ def _cred_route_audit(action: str, name: str, scope: str) -> None:
2649
+ """Names only — never values. Failure-safe."""
2650
+ try:
2651
+ from services.activity_log import ActivityLog
2652
+ ActivityLog(str(PROJECT_PATH)).log("cred_action", {
2653
+ "kind": "creds", "action": action, "name": name,
2654
+ "scope": scope, "via": "ui",
2655
+ })
2656
+ except Exception:
2657
+ pass
2658
+ try:
2659
+ from services.edit_ledger import EditLedger
2660
+ EditLedger(str(PROJECT_PATH)).log_edit(
2661
+ file=f"cred://{name}", change_type=f"cred_{action}",
2662
+ summary=f"{action} {name} ({scope}) via Credentials UI",
2663
+ tags=["creds", action],
2664
+ detail={"kind": "creds", "action": action, "name": name,
2665
+ "scope": scope},
2666
+ )
2667
+ except Exception:
2668
+ pass
2669
+
2670
+
2671
+ @app.route('/api/credentials', methods=['GET'])
2672
+ def api_credentials_list():
2673
+ """Masked entry list (values never included)."""
2674
+ from services import credential_store as cred_store
2675
+ pp = str(PROJECT_PATH)
2676
+ usage = cred_store.read_usage_state(pp)
2677
+ out = []
2678
+ for name, entry in cred_store.list_entries(pp).items():
2679
+ rec = dict(entry)
2680
+ rec["name"] = name
2681
+ rec["last_used"] = (usage.get(name) or {}).get("last_used", "")
2682
+ rec["use_count"] = (usage.get(name) or {}).get("use_count", 0)
2683
+ out.append(rec)
2684
+ return jsonify({"entries": out})
2685
+
2686
+
2687
+ @app.route('/api/credentials', methods=['POST'])
2688
+ def api_credentials_set():
2689
+ """Create/update an entry. `value` optional — metadata-only update when
2690
+ absent; a submitted value is stored and never echoed back."""
2691
+ from services import credential_store as cred_store
2692
+ data = request.get_json() or {}
2693
+ name = str(data.get("name") or "").strip()
2694
+ scope = str(data.get("scope") or "project").strip()
2695
+ value = str(data.get("value") or "")
2696
+ ctype = str(data.get("type") or data.get("ctype") or "token")
2697
+ meta = {
2698
+ "description": str(data.get("description") or ""),
2699
+ "env_var": str(data.get("env_var") or ""),
2700
+ "agent_readable": bool(data.get("agent_readable")),
2701
+ "inject": bool(data.get("inject")),
2702
+ }
2703
+ try:
2704
+ if value:
2705
+ entry = cred_store.set_credential(
2706
+ name, value, scope=scope, project_path=str(PROJECT_PATH),
2707
+ ctype=ctype, **meta)
2708
+ else:
2709
+ # Metadata-only update: touch ONLY the keys present in the payload
2710
+ # so a single-field toggle can't clobber the others.
2711
+ fields = {}
2712
+ for key in ("description", "env_var"):
2713
+ if key in data:
2714
+ fields[key] = str(data[key] or "")
2715
+ for key in ("agent_readable", "inject"):
2716
+ if key in data:
2717
+ fields[key] = bool(data[key])
2718
+ if "type" in data or "ctype" in data:
2719
+ fields["type"] = ctype
2720
+ entry = cred_store.update_metadata(
2721
+ name, scope=scope, project_path=str(PROJECT_PATH), **fields)
2722
+ except cred_store.CredentialError as exc:
2723
+ return jsonify({"error": str(exc)}), 400
2724
+ except RuntimeError as exc:
2725
+ return jsonify({"error": str(exc)}), 500
2726
+ entry = dict(entry)
2727
+ entry["name"] = name
2728
+ entry["scope"] = scope
2729
+ _cred_route_audit("set" if value else "update", name, scope)
2730
+ return jsonify({"entry": entry})
2731
+
2732
+
2733
+ @app.route('/api/credentials/import', methods=['POST'])
2734
+ def api_credentials_import():
2735
+ """Import KEY=VALUE lines (.env paste). Values are stored, never echoed."""
2736
+ from services import credential_store as cred_store
2737
+ data = request.get_json() or {}
2738
+ text = str(data.get("text") or "")
2739
+ scope = str(data.get("scope") or "project").strip()
2740
+ try:
2741
+ result = cred_store.import_env(
2742
+ text, scope=scope, project_path=str(PROJECT_PATH),
2743
+ overwrite=bool(data.get("overwrite")),
2744
+ )
2745
+ except cred_store.CredentialError as exc:
2746
+ return jsonify({"error": str(exc)}), 400
2747
+ except RuntimeError as exc:
2748
+ return jsonify({"error": str(exc)}), 500
2749
+ for created in result["created"]:
2750
+ _cred_route_audit("set", created, scope)
2751
+ return jsonify(result)
2752
+
2753
+
2754
+ @app.route('/api/credentials/<name>', methods=['DELETE'])
2755
+ def api_credentials_delete(name):
2756
+ from services import credential_store as cred_store
2757
+ pp = str(PROJECT_PATH)
2758
+ scope = str(request.args.get("scope") or "").strip()
2759
+ if not scope:
2760
+ entry = cred_store.get_entry(name, project_path=pp)
2761
+ scope = entry.get("scope") or "project" if entry else "project"
2762
+ try:
2763
+ removed = cred_store.delete_credential(name, scope=scope, project_path=pp)
2764
+ except cred_store.CredentialError as exc:
2765
+ return jsonify({"error": str(exc)}), 400
2766
+ if removed:
2767
+ _cred_route_audit("delete", name, scope)
2768
+ return jsonify({"removed": bool(removed), "scope": scope})
2769
+
2770
+
2771
+ @app.route('/api/credentials/<name>/check', methods=['POST'])
2772
+ def api_credentials_check(name):
2773
+ """Resolvability probe — returns a fingerprint, never the value."""
2774
+ from services import credential_store as cred_store
2775
+ pp = str(PROJECT_PATH)
2776
+ entry = cred_store.get_entry(name, project_path=pp)
2777
+ if not entry:
2778
+ return jsonify({"error": f"no credential named '{name}'"}), 404
2779
+ return jsonify({
2780
+ "name": name,
2781
+ "scope": entry["scope"],
2782
+ "storage": entry.get("storage", "keyring"),
2783
+ "resolvable": cred_store.get_value(name, project_path=pp) is not None,
2784
+ "fingerprint": cred_store.fingerprint(name, project_path=pp),
2785
+ })
2786
+
2787
+
2641
2788
  @app.route('/api/delegate', methods=['POST'])
2642
2789
  def api_delegate():
2643
2790
  """Delegate a task to local LLM with optional streaming."""