@ictechgy/context-guard 0.5.1 → 0.7.0

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.
@@ -1506,17 +1506,65 @@ def normalize_scope(raw_scope: str | None) -> str:
1506
1506
  return scope
1507
1507
 
1508
1508
 
1509
+ def _user_scope_path_issue(
1510
+ path: Path,
1511
+ *,
1512
+ label: str,
1513
+ expected_directory: bool,
1514
+ allow_missing: bool,
1515
+ ) -> str | None:
1516
+ """Return a fail-closed reason for an existing user-scope path."""
1517
+ try:
1518
+ metadata = os.lstat(path)
1519
+ except FileNotFoundError:
1520
+ return None if allow_missing else f"{label} does not exist: {path}"
1521
+ except OSError as error:
1522
+ return f"could not inspect {label}: {error.__class__.__name__}"
1523
+
1524
+ if metadata.st_uid not in {0, os.geteuid()}:
1525
+ return f"refusing unsafe {label}: it is not owned by root or the effective user"
1526
+ if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
1527
+ return f"refusing unsafe {label}: group/world write bits are set"
1528
+ if expected_directory:
1529
+ if not stat.S_ISDIR(metadata.st_mode):
1530
+ return f"refusing unsafe {label}: it is not a directory"
1531
+ elif not stat.S_ISREG(metadata.st_mode):
1532
+ return f"refusing unsafe {label}: it is not a regular file"
1533
+ return None
1534
+
1535
+
1536
+ def _validate_user_scope_home(home: Path) -> None:
1537
+ issue = _user_scope_path_issue(
1538
+ home,
1539
+ label="resolved HOME",
1540
+ expected_directory=True,
1541
+ allow_missing=False,
1542
+ )
1543
+ if issue:
1544
+ raise SystemExit(issue)
1545
+
1546
+
1509
1547
  def resolve_scope_root(raw_root: str | None, scope: str) -> Path:
1510
1548
  if scope == "project":
1511
1549
  return resolve_setup_root(raw_root)
1512
1550
  home = Path.home().expanduser().resolve()
1513
1551
  if home == Path(home.anchor or "/"):
1514
1552
  raise SystemExit("Refusing user-scope setup because HOME resolves to a filesystem root.")
1515
- if not home.exists() or not home.is_dir():
1516
- raise SystemExit(f"Refusing user-scope setup because HOME is not a directory: {home}")
1553
+ _validate_user_scope_home(home)
1517
1554
  return home
1518
1555
 
1519
1556
 
1557
+ def effective_scope(raw_root: str | None, raw_scope: str | None, *, allow_home_settings: bool) -> str:
1558
+ scope = normalize_scope(raw_scope)
1559
+ if scope != "project" or not allow_home_settings:
1560
+ return scope
1561
+ project_root = resolve_setup_root(raw_root)
1562
+ home_settings = Path.home().expanduser().resolve() / SETTINGS_REL
1563
+ if (project_root / SETTINGS_REL).expanduser().resolve() == home_settings:
1564
+ return "user"
1565
+ return scope
1566
+
1567
+
1520
1568
  def explicit_agent_selection(args: argparse.Namespace) -> list[str] | None:
1521
1569
  values: list[str] = []
1522
1570
  for attr in ("agent", "only"):
@@ -1549,6 +1597,20 @@ def validate_settings_target(root: Path, settings_path: Path, *, allow_home_sett
1549
1597
  claude_dir.resolve().relative_to(root)
1550
1598
  except ValueError as exc:
1551
1599
  raise SystemExit(f"Claude settings directory resolves outside project root: {claude_dir}") from exc
1600
+ if root == home_settings.parent.parent:
1601
+ for path, label, expected_directory, allow_missing in (
1602
+ (root, "resolved HOME", True, False),
1603
+ (claude_dir, "existing .claude directory", True, True),
1604
+ (settings_path, "existing settings.json", False, True),
1605
+ ):
1606
+ issue = _user_scope_path_issue(
1607
+ path,
1608
+ label=label,
1609
+ expected_directory=expected_directory,
1610
+ allow_missing=allow_missing,
1611
+ )
1612
+ if issue:
1613
+ raise SystemExit(issue)
1552
1614
 
1553
1615
 
1554
1616
  def _base_open_flags() -> int:
@@ -2970,14 +3032,21 @@ def doctor_check(
2970
3032
  return check
2971
3033
 
2972
3034
 
2973
- def _setup_command(args: argparse.Namespace, *, apply: bool, root: Path | None = None) -> str:
2974
- parts = ["context-guard", "setup", "--scope", normalize_scope(getattr(args, "scope", "project"))]
2975
- if root is not None and normalize_scope(getattr(args, "scope", "project")) == "project":
3035
+ def _setup_command(
3036
+ args: argparse.Namespace,
3037
+ *,
3038
+ apply: bool,
3039
+ root: Path | None = None,
3040
+ scope: str | None = None,
3041
+ ) -> str:
3042
+ scope = scope or normalize_scope(getattr(args, "scope", "project"))
3043
+ parts = ["context-guard", "setup", "--scope", scope]
3044
+ if root is not None and scope == "project":
2976
3045
  parts.extend(["--root", str(root)])
2977
3046
  selected = explicit_agent_selection(args)
2978
3047
  if selected:
2979
3048
  parts.extend(["--agent", ",".join(selected)])
2980
- elif normalize_scope(getattr(args, "scope", "project")) == "user":
3049
+ elif scope == "user":
2981
3050
  parts.extend(["--agent", "claude"])
2982
3051
  if getattr(args, "allow_path_helper_fallback", False):
2983
3052
  parts.append("--allow-path-helper-fallback")
@@ -3068,7 +3137,11 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
3068
3137
  writing settings, writing rule files, or creating rollback records.
3069
3138
  """
3070
3139
  require_no_follow_file_ops_supported()
3071
- scope = normalize_scope(getattr(args, "scope", "project"))
3140
+ scope = effective_scope(
3141
+ args.root,
3142
+ getattr(args, "scope", "project"),
3143
+ allow_home_settings=bool(getattr(args, "allow_home_settings", False)),
3144
+ )
3072
3145
  root = resolve_scope_root(args.root, scope)
3073
3146
  settings_path = root / SETTINGS_REL
3074
3147
  helper_check = _helper_availability_check(include_diet=not getattr(args, "no_diet_scan", False), allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)))
@@ -3190,7 +3263,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
3190
3263
  "medium",
3191
3264
  "ContextGuard setup is not fully applied for the requested selections.",
3192
3265
  detail={"planned_action_count": len(actions), "planned_actions": actions},
3193
- next_action=_setup_command(args, apply=False, root=root),
3266
+ next_action=_setup_command(args, apply=False, root=root, scope=scope),
3194
3267
  ))
3195
3268
  else:
3196
3269
  checks.append(doctor_check(
@@ -3225,7 +3298,7 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
3225
3298
  "medium",
3226
3299
  "Some requested adapters still have planned or unsupported setup actions.",
3227
3300
  detail={"adapters": adapter_warnings},
3228
- next_action=_setup_command(args, apply=False, root=root),
3301
+ next_action=_setup_command(args, apply=False, root=root, scope=scope),
3229
3302
  ))
3230
3303
  else:
3231
3304
  checks.append(doctor_check(
@@ -3279,9 +3352,9 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
3279
3352
  detail=diet_scan,
3280
3353
  ))
3281
3354
 
3282
- recommended = [_setup_command(args, apply=False, root=root)]
3355
+ recommended = [_setup_command(args, apply=False, root=root, scope=scope)]
3283
3356
  if changed or adapter_warnings:
3284
- recommended.append(_setup_command(args, apply=True, root=root))
3357
+ recommended.append(_setup_command(args, apply=True, root=root, scope=scope))
3285
3358
  return {
3286
3359
  "schema_version": "contextguard.doctor.v1",
3287
3360
  "status": _doctor_status(checks),
@@ -4050,7 +4123,11 @@ def render_quiet_narration_text(result: dict[str, Any]) -> str:
4050
4123
 
4051
4124
  def run(args: argparse.Namespace) -> SetupResult:
4052
4125
  require_no_follow_file_ops_supported()
4053
- scope = normalize_scope(getattr(args, "scope", "project"))
4126
+ scope = effective_scope(
4127
+ args.root,
4128
+ getattr(args, "scope", "project"),
4129
+ allow_home_settings=bool(getattr(args, "allow_home_settings", False)),
4130
+ )
4054
4131
  root = resolve_scope_root(args.root, scope)
4055
4132
  settings_path = root / SETTINGS_REL
4056
4133
  warnings: list[str] = []