Skip to content
Merged
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
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,67 @@ user, don't loop.

---

## Dry-run before writes

Before any `post` / `patch` / `delete` / `attach upload`, run with `--dry-run`
and `-f json` to get a structured preview the user can sanity-check:

```bash
bcli --dry-run -f json post customers --data '{"displayName": "Test"}'
```

The response is a JSON envelope:

```json
{
"dry_run": true,
"method": "POST",
"endpoint": "customers",
"resolved_url": "https://.../api/v2.0/companies(<id>)/customers",
"profile": "dev",
"environment": "Sandbox",
"company_id": "<id>",
"body": {"displayName": "Test"}
}
```

`PATCH` and `DELETE` envelopes also include `record_id`. The shape is stable —
field names won't change. Use it to:

* Show the user the resolved URL + body before they approve a real write.
* Catch typos in the endpoint name before any HTTP call goes out.
* Verify the right environment / company is targeted.

## Caution levels for write endpoints

Every endpoint exposes a `caution` level (`low` / `medium` / `high`) via
`bcli endpoint info` and the `list_endpoints` MCP tool. Endpoints whose name
contains a mutation verb (`post`, `release`, `cancel`, `void`, `reverse`,
`apply`, `unapply`) are flagged `high` automatically. Treat `high` as: "do
not write without explicit user confirmation, even if the user previously
authorised a similar action." Examples:

* `customers` → `low` (CRUD on a master-data record)
* `salesInvoicePost` → `high` (irreversibly posts an invoice)
* `customerLedgerEntryApply` → `high` (modifies posted ledger state)

## Audit log location

When the user has `[audit] enabled = true` in `~/.config/bcli/config.toml`,
every write you trigger appends a JSON line to
`~/.config/bcli/audit/<profile>.jsonl` (or whatever the user configured).
Each entry includes `outcome` (`completed` / `failed` / `dry_run`),
`correlation_id` (BC's `x-ms-correlation-request-id`), `latency_ms`, and the
redacted request body. Useful for:

* Showing the user "here's what just happened" after a multi-step task.
* Grepping for the BC correlation ID when debugging a 500 the user reported.
* Reconciling intent (`dry_run` entries) against actual writes.

You don't need to enable the log yourself — it's the user's choice. If they
ask "did that POST go through?" the audit log is the canonical answer when it's
on, and the CLI exit code is the answer when it's off.

## When you have an MCP server

If the user has mounted `bcli-mcp` (see [`docs/mcp-server.md`](docs/mcp-server.md)),
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] — 2026-05-06

### Added

- **Structured `--dry-run` output** — write commands (`post`, `patch`,
`delete`, `attach upload`) now emit a stable JSON envelope on stdout
when `--format json` / `ndjson` / `raw` is selected. Includes
`dry_run`, `method`, `endpoint`, `resolved_url`, `profile`,
`environment`, `company_id`, `body`, and `record_id` (when applicable).
Agents can parse the envelope before deciding whether to proceed. The
human format keeps the same yellow rich panel on stderr but is now
augmented with the resolved URL and profile context. See
`docs/write-operations.md`.
- **Opt-in audit log** — new `[audit]` config section persists every
write to a per-profile JSONL file. Each entry captures the resolved
URL, response status, BC `correlation_id`, latency, redacted request
body, and outcome (`completed` / `failed` / `dry_run`). Bounded disk
usage via single-backup rotation. SDK (`AsyncBCClient`) does NOT
auto-emit; this is a CLI-layer ergonomic on top of BC permission sets.
See `docs/configuration.md#audit-log`.
- **Endpoint `caution` flag** — `EndpointMetadata` now carries a
`caution: low | medium | high` level. Importers populate it
automatically from a verb-name heuristic (entities containing `post`,
`release`, `cancel`, `void`, `reverse`, `apply`, `unapply` are flagged
`high`). Surfaced in `bcli endpoint info` and the `list_endpoints` MCP
tool so agents can require explicit user confirmation before mutating
posted/closed records.
- New `AGENTS.md` recipes for dry-run-first writes, caution-level
interpretation, and audit-log location.

## [0.1.5] — 2026-05-05

### Added
Expand Down
64 changes: 64 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,69 @@ api_version = "v1.0"
Imported endpoint registries are preferred; route defaults are only an escape
hatch for ad-hoc access.

## Audit Log

Optional, opt-in audit trail for write operations. When enabled, every CLI write
(POST / PATCH / DELETE / attach upload) appends one JSONL line to a per-profile
file. Captures the resolved URL, response status, BC correlation ID, latency,
and outcome — enough to reconstruct what happened (and what was attempted).

Add an `[audit]` block to `~/.config/bcli/config.toml`:

```toml
[audit]
enabled = true
backend = "jsonl"
path = "~/.config/bcli/audit/{profile}.jsonl" # default; {profile} interpolated
max_size_mb = 50
include_reads = false
redact_keys = ["password", "secret", "token", "key", "apiKey", "authorization"]
```

| Setting | Default | What it does |
|---------|---------|--------------|
| `enabled` | `false` | Master switch. When false, the audit code path is a no-op. |
| `backend` | `"jsonl"` | `"jsonl"` (file) or `"null"` (drop). |
| `path` | `~/.config/bcli/audit/{profile}.jsonl` | File location. `{profile}` is interpolated to the active profile name. |
| `max_size_mb` | `50` | Rotation threshold. When the file exceeds this, the existing content moves to `<path>.1` and a fresh file is started. Only one backup is kept. |
| `include_reads` | `false` | Reserved for a future release; currently writes only. |
| `redact_keys` | `["password", "secret", ...]` | Substring-matched (case-insensitive) on request-body field names. Matched values are replaced with `***REDACTED***` before write. |

Each entry is one JSON object with the following keys:

```json
{
"ts": "2026-05-06T10:00:00Z",
"profile": "production",
"environment": "Production",
"company_id": "<company-id>",
"method": "POST",
"endpoint": "customers",
"resolved_url": "https://api.businesscentral.dynamics.com/.../customers",
"record_id": null,
"request_body": {"displayName": "Test"},
"status": 201,
"correlation_id": "abc-...",
"latency_ms": 312,
"cli_version": "0.2.0",
"caller": "cli",
"outcome": "completed",
"error": null
}
```

`outcome` is one of:

- `completed` — the write succeeded.
- `failed` — the write raised; `status`, `correlation_id`, and `error` capture
what BC said.
- `dry_run` — the user passed `--dry-run`; no HTTP call fired but the intent
is recorded.

The SDK (`AsyncBCClient`) does NOT auto-emit. Audit is a CLI-layer ergonomic;
programmatic SDK users get unfiltered access by design and can wire their own
logging.

## File Locations

| File | Purpose |
Expand All @@ -135,4 +198,5 @@ hatch for ad-hoc access.
| `~/.config/bcli/tokens.json` | Cached auth tokens |
| `~/.config/bcli/registries/*.json` | Imported custom API registries |
| `~/.config/bcli/queries/*.yaml` | Saved queries |
| `~/.config/bcli/audit/*.jsonl` | Per-profile audit log (when `[audit] enabled = true`) |
| `.bcli.toml` | Project-level config override |
44 changes: 41 additions & 3 deletions docs/write-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,52 @@ bcli delete customers "a1b2c3d4-..." --etag 'W/"ABC123"'

## Dry Run

Preview write operations without executing:
Preview write operations without executing. Works on `post`, `patch`, `delete`,
and `attach upload`. The output adapts to `--format`:

**Human format (default):** rich panel on stderr with the resolved URL, profile
context, and the request body.

```bash
bcli --dry-run post customers --data '{"displayName": "Test"}'
# --dry-run: would POST to customers
# {"displayName": "Test"}
# --dry-run: would POST customers
# URL: https://api.businesscentral.dynamics.com/.../api/v2.0/companies(<id>)/customers
# Profile: dev
# Env: Sandbox
# Company: <company-id>
# {
# "displayName": "Test"
# }
```

**Machine format (`-f json` / `-f ndjson` / `-f raw`):** a single JSON envelope
on stdout that an agent can parse before deciding whether to proceed:

```bash
bcli --dry-run -f json post customers --data '{"displayName": "Test"}'
```

```json
{
"dry_run": true,
"method": "POST",
"endpoint": "customers",
"resolved_url": "https://api.businesscentral.dynamics.com/.../customers",
"profile": "dev",
"environment": "Sandbox",
"company_id": "<company-id>",
"body": {"displayName": "Test"}
}
```

`PATCH` and `DELETE` envelopes also include `record_id`. `attach upload` adds
`file_path`, `byte_size`, `parent_type`, and `parent_id`. The envelope shape is
stable — agents can rely on the field names.

When the audit log is enabled (see [Audit Log](configuration.md#audit-log)), each
dry-run is recorded with `outcome: "dry_run"` so the paper trail captures intent
even when no HTTP call fires.

## Custom API Routes

For custom endpoints not in the registry:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "hatchling.build"
# installed CLI binary (`bcli`) are unaffected — only `pip install` /
# `uv tool install` use this name.
name = "bc-cli"
version = "0.1.5"
version = "0.2.0"
description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs"
readme = "README.md"
license = "Apache-2.0"
Expand Down
27 changes: 27 additions & 0 deletions src/bcli/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Opt-in audit log for bcli write operations.

>>> from bcli.audit import get_audit_sink, AuditEntry
>>> sink = get_audit_sink(config.audit, profile_name="dev") # NullSink if disabled
>>> sink.emit(AuditEntry(...))
"""

from __future__ import annotations

from bcli.audit._factory import get_audit_sink
from bcli.audit._protocol import (
AuditEntry,
AuditSink,
JSONLAuditSink,
NullAuditSink,
)
from bcli.audit._redact import REDACTED, redact

__all__ = [
"AuditEntry",
"AuditSink",
"JSONLAuditSink",
"NullAuditSink",
"REDACTED",
"get_audit_sink",
"redact",
]
74 changes: 74 additions & 0 deletions src/bcli/audit/_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Build an :class:`AuditSink` from an :class:`AuditConfig`.

Returns :class:`NullAuditSink` when audit is disabled or the chosen
backend cannot be loaded — callers can ``emit()`` unconditionally.
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

from bcli.audit._protocol import AuditSink, JSONLAuditSink, NullAuditSink

if TYPE_CHECKING:
from bcli.config._model import AuditConfig

logger = logging.getLogger("bcli.audit")


def get_audit_sink(
config: "AuditConfig | None",
*,
profile_name: str,
) -> AuditSink:
"""Build an audit sink for the given profile.

``profile_name`` is interpolated into ``config.path`` (template token
``{profile}``) so a single global config produces one file per
profile automatically.
"""
if config is None or not config.enabled:
return NullAuditSink()

backend = (config.backend or "jsonl").strip().lower()
if backend == "null":
return NullAuditSink()

if backend == "jsonl":
try:
path = _resolve_path(config.path, profile_name=profile_name)
except Exception as exc: # noqa: BLE001
logger.warning(
"audit path resolution failed (%s); falling back to NullAuditSink", exc
)
return NullAuditSink()
return JSONLAuditSink(
path=path,
max_size_bytes=int(config.max_size_mb) * 1024 * 1024,
)

logger.warning(
"unknown audit backend '%s'; falling back to NullAuditSink. "
"Built-in choices: 'jsonl', 'null'.",
config.backend,
)
return NullAuditSink()


def _resolve_path(
template: str | None,
*,
profile_name: str,
) -> Path:
"""Expand the configured path, falling back to the documented default
when the user didn't set one."""
if template:
expanded = template.format(profile=profile_name)
else:
# Default: ~/.config/bcli/audit/{profile}.jsonl
expanded = str(
Path.home() / ".config" / "bcli" / "audit" / f"{profile_name}.jsonl"
)
return Path(expanded).expanduser()
Loading
Loading