Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ numeric next development line is tracked in `DEVELOPMENT_VERSION`.

## [Unreleased]

### Changed

- Make project-less terminal `basectl trust status` output a compact workspace
table with blocked projects first, short reasons, and an aggregate summary.

### Fixed

- Use the same readable blocked-trust explanations in workspace test preflight,
Expand Down
6 changes: 3 additions & 3 deletions cli/bash/commands/basectl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ such command directories exist. Optional utility CLIs such as `caff` and
history index and supports table, JSON, privacy-conscious reports, chronological
ordering, and bounded time-window filters.
- `basectl config path/show/doctor` inspects Base's machine-local user config at `~/.base.d/config.yaml`.
- `basectl trust status [project]` inspects one project's manifest command
trust or all discovered command-bearing projects. `basectl trust
allow/revoke <project>` manages local approval under
- `basectl trust status [project]` shows detailed trust guidance for one
project or a compact, blocked-first table for discovered command-bearing
projects. `basectl trust allow/revoke <project>` manages local approval under
`~/.base.d/trust/manifest-commands/`.
- `basectl doctor [project]` diagnoses the local Base environment and, when
provided, project manifest artifacts with suggested fixes.
Expand Down
2 changes: 1 addition & 1 deletion cli/bash/commands/basectl/tests/help.bats
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ PY
@test "command reference documents trust commands" {
local command_reference="$BASE_REPO_ROOT/docs/command-reference.md"

grep -Fqx -- "| \`basectl trust status [project]\` | Show one project's manifest trust status, or all discovered command-bearing projects. | \`--project <name>\`, \`--workspace <path>\`, \`--format <text\\|csv\\|tsv\\|yaml\\|json>\` |" "$command_reference"
grep -Fqx -- "| \`basectl trust status [project]\` | Show detailed trust guidance for one project, or a compact blocked-first table for discovered command-bearing projects. | \`--project <name>\`, \`--workspace <path>\`, \`--format <text\\|csv\\|tsv\\|yaml\\|json>\` |" "$command_reference"
grep -Fqx -- "| \`basectl trust allow <project>\` | Approve the current manifest command contract on this machine. | \`--project <name>\`, \`--workspace <path>\`, \`--manifest-sha256 <sha256>\`, \`--test-requirements-sha256 <sha256>\` |" "$command_reference"
grep -Fqx -- "| \`basectl trust revoke <project>\` | Remove local manifest command approval. | \`--project <name>\`, \`--workspace <path>\` |" "$command_reference"
}
Expand Down
63 changes: 55 additions & 8 deletions cli/python/base_trust/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ class TrustError(RuntimeError):

def workspace_status_command(ctx: base_cli.Context, workspace: str | None, output_format: str) -> int:
try:
projects = workspace_status_projects(ctx, workspace)
from base_projects.workspace_context import resolve_workspace_root

workspace_root = resolve_workspace_root(ctx, workspace)
projects = workspace_status_projects(ctx, workspace, workspace_root)
store = ManifestCommandTrustStore()
statuses = []
for project in projects:
Expand Down Expand Up @@ -237,21 +240,65 @@ def workspace_status_command(ctx: base_cli.Context, workspace: str | None, outpu
return base_cli.ExitCode.SUCCESS

if not statuses:
print("No discovered projects require manifest command trust.")
_print_workspace_status_text(workspace_root, ())
return base_cli.ExitCode.SUCCESS

for index, (trust_status, surfaces) in enumerate(statuses):
if index:
print()
print_status_text(trust_status, surfaces)
_print_workspace_status_text(workspace_root, tuple(trust_status for trust_status, _surfaces in statuses))
return base_cli.ExitCode.SUCCESS


def workspace_status_projects(ctx: base_cli.Context, workspace: str | None) -> tuple[Project, ...]:
def _print_workspace_status_text(workspace_root: Path, statuses: tuple[TrustStatus, ...]) -> None:
project_count = len(statuses)
project_label = "project" if project_count == 1 else "projects"
print(f"Workspace trust status: {workspace_root} ({project_count} command-bearing {project_label})")

if not statuses:
print("\nNo command-bearing projects require manifest command trust.")
print("Summary: 0 blocked, 0 allowed.")
return

display_statuses = sorted(
statuses,
key=lambda trust_status: (trust_status.is_allowed, trust_status.identity.project_name.casefold()),
)
project_width = max(len("PROJECT"), *(len(item.identity.project_name) for item in display_statuses))
print()
print(f"{'PROJECT':<{project_width}} {'STATUS':<7} REASON")
for trust_status in display_statuses:
project_name = trust_status.identity.project_name
status_label = "Allowed" if trust_status.is_allowed else "Blocked"
reason_label = _workspace_status_reason_label(trust_status.reason)
print(f"{project_name:<{project_width}} {status_label:<7} {reason_label}")

blocked_count = sum(1 for trust_status in statuses if not trust_status.is_allowed)
allowed_count = project_count - blocked_count
print(f"\nSummary: {blocked_count} blocked, {allowed_count} allowed.")
print("Details: basectl trust status <project>")
print("Trust scope: approval binds manifest and declared test-requirements digests.")
print(" Referenced scripts, direct executables, and Git HEAD are not covered.")
print(" Uncommitted working-tree changes are not checked.")


def _workspace_status_reason_label(reason: str) -> str:
labels = {
"allowed": "Approved",
"not_allowed": "Not approved",
"manifest_changed": "Manifest changed",
"test_requirements_changed": "Test requirements changed",
}
return labels.get(reason, reason.replace("_", " ").capitalize())


def workspace_status_projects(
ctx: base_cli.Context,
workspace: str | None,
workspace_root: Path | None = None,
) -> tuple[Project, ...]:
from base_projects.project_discovery import discover_projects_cached, read_project
from base_projects.workspace_context import resolve_workspace_root

workspace_root = resolve_workspace_root(ctx, workspace)
if workspace_root is None:
workspace_root = resolve_workspace_root(ctx, workspace)
projects_by_name = {project.name: project for project in discover_projects_cached(ctx, workspace_root)}

if workspace is None:
Expand Down
118 changes: 117 additions & 1 deletion cli/python/base_trust/tests/test_command_error_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,123 @@ def test_workspace_status_supports_yaml_and_empty_terminal_output(

assert yaml.safe_load(yaml_result.stdout)["projects"][0]["project"]["name"] == "demo"
assert text_result.exit_code == 0, text_result.output
assert text_result.stdout == "No discovered projects require manifest command trust.\n"
assert text_result.stdout == (
f"Workspace trust status: {empty_workspace.resolve()} (0 command-bearing projects)\n"
"\nNo command-bearing projects require manifest command trust.\n"
"Summary: 0 blocked, 0 allowed.\n"
)


def test_workspace_status_text_shows_allowed_projects_compactly(
tmp_path: Path,
manifest_factory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
home = tmp_path / "home"
workspace = tmp_path / "work"
manifests = [
manifest_factory.write(workspace / name, name=name)
for name in ("zeta", "alpha")
]
store = engine.ManifestCommandTrustStore(home=home)
for manifest_path in manifests:
store.allow(engine.compute_trust_identity_for_manifest(manifest_path), base_version="1.10.5")

monkeypatch.setattr(engine.base_cli, "is_terminal", lambda: True)
result = invoke(
engine.app,
["status", "--workspace", str(workspace)],
home=home,
env={"BASE_HOME": str(workspace / "base")},
)

assert result.exit_code == 0, result.output
assert f"Workspace trust status: {workspace.resolve()} (2 command-bearing projects)" in result.stdout
assert "PROJECT STATUS REASON" in result.stdout
assert "alpha Allowed Approved" in result.stdout
assert "zeta Allowed Approved" in result.stdout
assert "Summary: 0 blocked, 2 allowed." in result.stdout
assert result.stdout.count("Trust scope:") == 1
assert "Details: basectl trust status <project>" in result.stdout
assert "SHA-256" not in result.stdout
assert "Allow after review" not in result.stdout


def test_workspace_status_text_summarizes_blocked_reasons_before_allowed_projects(
tmp_path: Path,
manifest_factory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
home = tmp_path / "home"
workspace = tmp_path / "work"
allowed_manifest = manifest_factory.write(workspace / "allowed", name="allowed")
changed_manifest = manifest_factory.write(workspace / "changed-manifest", name="changed-manifest")
requirements_manifest = manifest_factory.write(workspace / "changed-requirements", name="changed-requirements")
requirements_manifest.write_text(
requirements_manifest.read_text(encoding="utf-8").replace(
" command: pytest tests/\n",
" command: pytest tests/\n requirements: requirements-dev.txt\n",
),
encoding="utf-8",
)
requirements_path = requirements_manifest.parent / "requirements-dev.txt"
requirements_path.write_text("jsonschema==4.25.1\n", encoding="utf-8")

store = engine.ManifestCommandTrustStore(home=home)
store.allow(engine.compute_trust_identity_for_manifest(allowed_manifest), base_version="1.10.5")
store.allow(engine.compute_trust_identity_for_manifest(changed_manifest), base_version="1.10.5")
changed_manifest.write_text(
changed_manifest.read_text(encoding="utf-8").replace("pytest tests/", "pytest -q"),
encoding="utf-8",
)
store.allow(engine.compute_trust_identity_for_manifest(requirements_manifest), base_version="1.10.5")
requirements_path.write_text("jsonschema==4.25.2\n", encoding="utf-8")
manifest_factory.write(workspace / "fresh", name="fresh")

monkeypatch.setattr(engine.base_cli, "is_terminal", lambda: True)
result = invoke(
engine.app,
["status", "--workspace", str(workspace)],
home=home,
env={"BASE_HOME": str(workspace / "base")},
)

assert result.exit_code == 0, result.output
assert "changed-manifest Blocked Manifest changed" in result.stdout
assert "changed-requirements Blocked Test requirements changed" in result.stdout
assert "fresh Blocked Not approved" in result.stdout
assert "allowed Allowed Approved" in result.stdout
assert result.stdout.index("changed-manifest Blocked") < result.stdout.index("allowed Allowed")
assert "Summary: 3 blocked, 1 allowed." in result.stdout
assert result.stdout.count("Trust scope:") == 1
assert "Details: basectl trust status <project>" in result.stdout
assert "SHA-256" not in result.stdout
assert "Allow after review" not in result.stdout


def test_workspace_status_text_summarizes_all_blocked_projects(
tmp_path: Path,
manifest_factory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
home = tmp_path / "home"
workspace = tmp_path / "work"
manifest_factory.write(workspace / "zeta", name="zeta")
manifest_factory.write(workspace / "alpha", name="alpha")

monkeypatch.setattr(engine.base_cli, "is_terminal", lambda: True)
result = invoke(
engine.app,
["status", "--workspace", str(workspace)],
home=home,
env={"BASE_HOME": str(workspace / "base")},
)

assert result.exit_code == 0, result.output
assert "alpha Blocked Not approved" in result.stdout
assert "zeta Blocked Not approved" in result.stdout
assert "Summary: 2 blocked, 0 allowed." in result.stdout
assert result.stdout.count("Trust scope:") == 1


def test_invalid_status_format_is_a_usage_error(tmp_path: Path) -> None:
Expand Down
2 changes: 1 addition & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ is requested on macOS, Base warns if `osascript` is not available.
| `basectl demo [project]` | Run a project-owned demo script. | `--project <name>`, `--workspace <path>`, `--dry-run`, `-- <args>` |
| `basectl devcontainer [project]` | Preview or write `.devcontainer/devcontainer.json` from a Base manifest. Dry-run is the default. | `--project <name>`, `--workspace <path>`, `--format <text\|json>`, `--write` |
| `basectl devenv-report [project]` | Classify Base manifest fields for Nix/devenv planning without generating files or requiring Nix. | `--project <name>`, `--workspace <path>`, `--format <text\|json>` |
| `basectl trust status [project]` | Show one project's manifest trust status, or all discovered command-bearing projects. | `--project <name>`, `--workspace <path>`, `--format <text\|csv\|tsv\|yaml\|json>` |
| `basectl trust status [project]` | Show detailed trust guidance for one project, or a compact blocked-first table for discovered command-bearing projects. | `--project <name>`, `--workspace <path>`, `--format <text\|csv\|tsv\|yaml\|json>` |
| `basectl trust allow <project>` | Approve the current manifest command contract on this machine. | `--project <name>`, `--workspace <path>`, `--manifest-sha256 <sha256>`, `--test-requirements-sha256 <sha256>` |
| `basectl trust revoke <project>` | Remove local manifest command approval. | `--project <name>`, `--workspace <path>` |

Expand Down
7 changes: 7 additions & 0 deletions docs/manifest-command-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ reports every discovered project whose manifest declares an executable test,
run, build, demo, or activation surface. Metadata-only manifests are omitted so
workspace onboarding does not suggest unnecessary approval.

In the default terminal text view without a project, `trust status` prints a
compact workspace table with blocked projects first, a short reason for each
status, and aggregate allowed/blocked counts. It states the trust-scope limit
once and points to `basectl trust status <project>` for the detailed identity,
review, and allow guidance. An explicit project keeps the detailed text report;
JSON/YAML/CSV/TSV workspace output keeps its existing schema and columns.

For schema-v1 compatibility, explicit single-project JSON continues to report
the underlying allow-record state, including `status: blocked` and an
`allow_command` for a metadata-only manifest. Human text reports that approval
Expand Down
Loading