bind9-aiops 0.2.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.
@@ -0,0 +1,8 @@
1
+ """BIND 9 AIops: governed read-only DNS operations."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("bind9-aiops")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,9 @@
1
+ """CLI package for bind9-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``bind9-aiops = "bind9_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from bind9_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -0,0 +1,118 @@
1
+ """Shared helpers for bind9-aiops CLI sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from typing import Annotated, Any
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+
13
+ console = Console()
14
+
15
+ # ─── Shared Option types ───────────────────────────────────────────────────
16
+
17
+ TargetOption = Annotated[str | None, typer.Option("--target", "-t", help="Target name from config")]
18
+ DryRunOption = Annotated[
19
+ bool, typer.Option("--dry-run", help="Print the API call without executing")
20
+ ]
21
+
22
+
23
+ def _cli_error_types() -> tuple[type[BaseException], ...]:
24
+ """Exceptions translated to a one-line teaching error instead of a traceback.
25
+
26
+ ``BudgetExceeded`` (and the retained ``PolicyDenied`` type) are raised by
27
+ ``@governed_tool`` OUTSIDE the tool body, so ``tool_errors`` never sees them
28
+ and they never arrive as an ``{"error": ...}`` dict. Their message is the
29
+ teaching text (e.g. which budget was hit) — without them here such a refusal
30
+ reaches the CLI as a traceback instead.
31
+ """
32
+ from bind9_aiops.connection import BindCommandError
33
+ from bind9_aiops.governance import BudgetExceeded, PolicyDenied
34
+
35
+ return (BindCommandError, PolicyDenied, BudgetExceeded, KeyError, OSError, ValueError)
36
+
37
+
38
+ def cli_errors(fn: Callable) -> Callable:
39
+ """Translate known exceptions into one red line + exit code 1."""
40
+
41
+ @functools.wraps(fn)
42
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
43
+ try:
44
+ return fn(*args, **kwargs)
45
+ except (typer.Exit, typer.Abort):
46
+ raise
47
+ except _cli_error_types() as e:
48
+ message = str(e)
49
+ if isinstance(e, KeyError):
50
+ message = f"Missing required key or environment variable: {message}"
51
+ # Upstream text can contain "[/bold]"; unescaped, rich raises on it and the
52
+ # real error is replaced by a MarkupError traceback.
53
+ console.print(f"[red]Error: {escape(message)}[/]")
54
+ raise typer.Exit(1) from e
55
+
56
+ return wrapper
57
+
58
+
59
+ #: Exit status for a write whose outcome could not be determined — kept distinct
60
+ #: from 0 (confirmed) and 1 (failed) so a script can tell all three apart.
61
+ EXIT_UNDETERMINED = 2
62
+
63
+
64
+ def governed(result: Any) -> dict:
65
+ """Return a governed tool's result, or print its error and exit 1.
66
+
67
+ The ``bind9_aiops.mcp_server.tools`` twins never raise: ``@tool_errors`` flattens every
68
+ failure — an unreachable service, a refused login, a bad argument — into
69
+ ``{"error": ...}``. A CLI command that drops that on the floor prints an empty
70
+ table and exits 0 as if the service had nothing to report. Route every governed
71
+ call through here.
72
+ """
73
+ # ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
74
+ # write whose response was lost carries BOTH keys, and it is audited
75
+ # `unknown` precisely because it may have taken effect. Reporting that as a
76
+ # plain failure would tell a script the change did not happen and invite the
77
+ # double-apply the payload's own note warns about.
78
+ if isinstance(result, dict) and result.get("outcomeUnknown"):
79
+ console.print(f"[yellow]Outcome undetermined: {escape(str(result.get('note') or ''))}[/]")
80
+ raise typer.Exit(EXIT_UNDETERMINED)
81
+ if isinstance(result, dict) and result.get("error"):
82
+ console.print(f"[red]Error: {escape(str(result['error']))}[/]")
83
+ raise typer.Exit(1)
84
+ return result if isinstance(result, dict) else {}
85
+
86
+
87
+ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
88
+ """Print a dry-run preview of the API call that would be made."""
89
+ console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
90
+ console.print(f"[magenta] Operation: {operation}[/]")
91
+ console.print(f"[magenta] API Call: {api_call}[/]")
92
+ for k, v in (parameters or {}).items():
93
+ console.print(f"[magenta] Param: {k} = {v}[/]")
94
+ console.print("[magenta] Run without --dry-run to execute.[/]\n")
95
+
96
+
97
+ def double_confirm(action: str, resource: str) -> None:
98
+ """Require two confirmations for a destructive operation."""
99
+ console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
100
+ typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
101
+ typer.confirm(
102
+ f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
103
+ abort=True,
104
+ )
105
+
106
+
107
+ def print_truncation_note(result: dict, noun: str) -> None:
108
+ """Print a visible warning when a listing envelope was capped.
109
+
110
+ The JSON already carries ``truncated``, but a human scrolling a long dump
111
+ will not spot it — and neither will a model summarising the terminal. Say
112
+ it in words, on the last line, where it cannot be missed.
113
+ """
114
+ if isinstance(result, dict) and result.get("truncated"):
115
+ console.print(
116
+ f"[yellow]… showing {result.get('returned')} of more {noun} — "
117
+ f"truncated, re-run with a higher --limit[/]"
118
+ )
@@ -0,0 +1,249 @@
1
+ """BIND 9 AIops CLI; each server operation calls its governed MCP twin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ import yaml
11
+
12
+ from bind9_aiops.cli._common import cli_errors, console, double_confirm, governed
13
+ from bind9_aiops.cli.undo import undo_app
14
+ from bind9_aiops.governance.paths import ops_home
15
+
16
+ app = typer.Typer(name="bind9-aiops", no_args_is_help=True, help="Audited BIND 9 DNS diagnosis.")
17
+ app.add_typer(undo_app, name="undo")
18
+ Target = Annotated[str | None, typer.Option("--target", "-t", help="Configured BIND 9 target")]
19
+
20
+
21
+ def _print(result: dict) -> None:
22
+ console.print_json(json.dumps(governed(result)))
23
+
24
+
25
+ @app.command("fetch-limits")
26
+ @cli_errors
27
+ def fetch_limits(target: Target = None) -> None:
28
+ """Show recursive fetch limit hits."""
29
+ from bind9_aiops.mcp_server.tools.dns import bind_fetch_limits
30
+ _print(bind_fetch_limits(target=target))
31
+
32
+
33
+ @app.command("security-roots")
34
+ @cli_errors
35
+ def security_roots(target: Target = None) -> None:
36
+ """Show DNSSEC trust anchors through rndc, without a dump file."""
37
+ from bind9_aiops.mcp_server.tools.dns import bind_security_roots
38
+ _print(bind_security_roots(target=target))
39
+
40
+
41
+ @app.command("tcp-timeouts")
42
+ @cli_errors
43
+ def tcp_timeouts(target: Target = None) -> None:
44
+ """Show current DNS TCP timeouts."""
45
+ from bind9_aiops.mcp_server.tools.dns import bind_tcp_timeouts
46
+ _print(bind_tcp_timeouts(target=target))
47
+
48
+
49
+ @app.command("signing-status")
50
+ @cli_errors
51
+ def signing_status(zone: str, target: Target = None) -> None:
52
+ """Show DNSSEC signing state for a zone."""
53
+ from bind9_aiops.mcp_server.tools.dns import bind_signing_status
54
+ _print(bind_signing_status(zone=zone, target=target))
55
+
56
+
57
+ @app.command("dnssec-zone")
58
+ @cli_errors
59
+ def dnssec_zone(zone: str, target: Target = None) -> None:
60
+ """Show DNSSEC signing status for a zone."""
61
+ from bind9_aiops.mcp_server.tools.dns import bind_dnssec_zone_status
62
+ _print(bind_dnssec_zone_status(zone=zone, target=target))
63
+
64
+
65
+ @app.command("managed-keys")
66
+ @cli_errors
67
+ def managed_keys(target: Target = None) -> None:
68
+ """Show managed DNSSEC trust-key status."""
69
+ from bind9_aiops.mcp_server.tools.dns import bind_managed_keys_status
70
+ _print(bind_managed_keys_status(target=target))
71
+
72
+
73
+ @app.command("serve-stale")
74
+ @cli_errors
75
+ def serve_stale(target: Target = None) -> None:
76
+ """Show serve-stale mode."""
77
+ from bind9_aiops.mcp_server.tools.dns import bind_serve_stale_status
78
+ _print(bind_serve_stale_status(target=target))
79
+
80
+
81
+ @app.command("show-zone")
82
+ @cli_errors
83
+ def show_zone(zone: str, target: Target = None) -> None:
84
+ """Show running zone configuration when enabled."""
85
+ from bind9_aiops.mcp_server.tools.dns import bind_show_zone
86
+ _print(bind_show_zone(zone=zone, target=target))
87
+
88
+
89
+ @app.command("query-server")
90
+ @cli_errors
91
+ def query_server(name: str, server: str, rrtype: str = "A", target: Target = None) -> None:
92
+ """Query a specified DNS server."""
93
+ from bind9_aiops.mcp_server.tools.dns import bind_query_server
94
+ _print(bind_query_server(name=name, server=server, rrtype=rrtype, target=target))
95
+
96
+
97
+ @app.command("reload-zone")
98
+ @cli_errors
99
+ def reload_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
100
+ """Check config, then queue a zone reload."""
101
+ from bind9_aiops.mcp_server.tools.dns import bind_reload_zone
102
+ if not dry_run:
103
+ double_confirm("reload zone", zone)
104
+ _print(bind_reload_zone(zone=zone, dry_run=dry_run, target=target))
105
+
106
+
107
+ @app.command("refresh-zone")
108
+ @cli_errors
109
+ def refresh_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
110
+ """Request a secondary zone refresh."""
111
+ from bind9_aiops.mcp_server.tools.dns import bind_refresh_zone
112
+ if not dry_run:
113
+ double_confirm("refresh zone", zone)
114
+ _print(bind_refresh_zone(zone=zone, dry_run=dry_run, target=target))
115
+
116
+
117
+ @app.command("flush-name")
118
+ @cli_errors
119
+ def flush_name(name: str, dry_run: bool = False, target: Target = None) -> None:
120
+ """Flush one name from the DNS cache."""
121
+ from bind9_aiops.mcp_server.tools.dns import bind_flush_name
122
+ if not dry_run:
123
+ double_confirm("flush cached name", name)
124
+ _print(bind_flush_name(name=name, dry_run=dry_run, target=target))
125
+
126
+
127
+ @app.command("retransfer-zone")
128
+ @cli_errors
129
+ def retransfer_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
130
+ """Request a fresh transfer of one secondary zone."""
131
+ from bind9_aiops.mcp_server.tools.dns import bind_retransfer_zone
132
+ if not dry_run:
133
+ double_confirm("retransfer secondary zone", zone)
134
+ _print(bind_retransfer_zone(zone=zone, dry_run=dry_run, target=target))
135
+
136
+
137
+ @app.command("notify-zone")
138
+ @cli_errors
139
+ def notify_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
140
+ """Resend NOTIFY for one zone."""
141
+ from bind9_aiops.mcp_server.tools.dns import bind_notify_zone
142
+ if not dry_run:
143
+ double_confirm("resend zone NOTIFY", zone)
144
+ _print(bind_notify_zone(zone=zone, dry_run=dry_run, target=target))
145
+
146
+
147
+ @app.command("init")
148
+ @cli_errors
149
+ def init() -> None:
150
+ """Configure a local BIND 9 service target."""
151
+ name = typer.prompt("Target name", default="local")
152
+ server = typer.prompt("DNS server address", default="127.0.0.1")
153
+ from bind9_aiops.config import TargetConfig
154
+
155
+ chosen = TargetConfig(name=name, server=server)
156
+ path = ops_home() / "config.yaml"
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ if path.exists():
159
+ raise ValueError(f"Config already exists at {path}; edit it to add a target.")
160
+ path.write_text(yaml.safe_dump({"targets": [{"name": chosen.name, "server": chosen.server}]}))
161
+ path.chmod(0o600)
162
+ console.print(f"Configured {name} at {path}")
163
+
164
+
165
+ @app.command("status")
166
+ @cli_errors
167
+ def status(target: Target = None) -> None:
168
+ """Show named status."""
169
+ from bind9_aiops.mcp_server.tools.dns import bind_status
170
+
171
+ _print(bind_status(target=target))
172
+
173
+
174
+ @app.command("config-check")
175
+ @cli_errors
176
+ def config_check(target: Target = None) -> None:
177
+ """Validate config and zone files."""
178
+ from bind9_aiops.mcp_server.tools.dns import bind_config_check
179
+
180
+ _print(bind_config_check(target=target))
181
+
182
+
183
+ @app.command("zone-status")
184
+ @cli_errors
185
+ def zone_status(zone: str, target: Target = None) -> None:
186
+ """Show live zone status."""
187
+ from bind9_aiops.mcp_server.tools.dns import bind_zone_status
188
+
189
+ _print(bind_zone_status(zone=zone, target=target))
190
+
191
+
192
+ @app.command("dnssec")
193
+ @cli_errors
194
+ def dnssec(target: Target = None) -> None:
195
+ """Show DNSSEC validation status."""
196
+ from bind9_aiops.mcp_server.tools.dns import bind_dnssec_validation
197
+
198
+ _print(bind_dnssec_validation(target=target))
199
+
200
+
201
+ @app.command("query")
202
+ @cli_errors
203
+ def query(name: str, rrtype: str = "A", target: Target = None) -> None:
204
+ """Query a DNS record."""
205
+ from bind9_aiops.mcp_server.tools.dns import bind_query
206
+
207
+ _print(bind_query(name=name, rrtype=rrtype, target=target))
208
+
209
+
210
+ @app.command("compare-soa")
211
+ @cli_errors
212
+ def compare_soa(zone: str, secondary: str, target: Target = None) -> None:
213
+ """Compare primary and secondary SOA serials."""
214
+ from bind9_aiops.mcp_server.tools.dns import bind_compare_soa
215
+
216
+ _print(bind_compare_soa(zone=zone, secondary=secondary, target=target))
217
+
218
+
219
+ @app.command("diagnose")
220
+ @cli_errors
221
+ def diagnose(zone: str, target: Target = None) -> None:
222
+ """Diagnose a BIND 9 zone."""
223
+ from bind9_aiops.mcp_server.tools.dns import bind_diagnose_zone
224
+
225
+ _print(bind_diagnose_zone(zone=zone, target=target))
226
+
227
+
228
+ @app.command("doctor")
229
+ @cli_errors
230
+ def doctor(target: Target = None) -> None:
231
+ """Test access to rndc status."""
232
+ from bind9_aiops.mcp_server.tools.dns import bind_status
233
+
234
+ _print(bind_status(target=target))
235
+
236
+
237
+ @app.command("mcp")
238
+ @cli_errors
239
+ def mcp_cmd() -> None:
240
+ """Start stdio MCP server."""
241
+ if sys.version_info < (3, 11):
242
+ raise typer.Exit(2)
243
+ from bind9_aiops.mcp_server.server import main
244
+
245
+ main()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ app()
@@ -0,0 +1,66 @@
1
+ """``bind9-aiops undo`` — list recorded undo tokens and apply them.
2
+
3
+ Real execution is delegated to the ``@governed_tool``-wrapped functions in
4
+ ``bind9_aiops.mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
5
+ path as any other write (the inverse tool it dispatches is itself governed).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from bind9_aiops.cli._common import (
16
+ DryRunOption,
17
+ TargetOption,
18
+ cli_errors,
19
+ console,
20
+ double_confirm,
21
+ dry_run_print,
22
+ governed,
23
+ print_truncation_note,
24
+ )
25
+
26
+ undo_app = typer.Typer(
27
+ name="undo",
28
+ help="List recorded undo tokens and apply their inverse operations.",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+ UndoIdArg = Annotated[str, typer.Argument(help="Undo id from 'undo list'")]
33
+
34
+
35
+ @undo_app.command("list")
36
+ @cli_errors
37
+ def undo_list_cmd(
38
+ limit: Annotated[int, typer.Option("--limit", help="Max tokens to show")] = 50,
39
+ target: TargetOption = None,
40
+ ) -> None:
41
+ """List recorded, not-yet-applied undo tokens."""
42
+ from bind9_aiops.mcp_server.tools import undo as gov
43
+
44
+ result = governed(gov.undo_list(limit=limit, target=target))
45
+ console.print_json(json.dumps(result))
46
+ print_truncation_note(result, "undo tokens")
47
+
48
+
49
+ @undo_app.command("apply")
50
+ @cli_errors
51
+ def undo_apply_cmd(
52
+ undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
53
+ ) -> None:
54
+ """Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
55
+ from bind9_aiops.mcp_server.tools import undo as gov
56
+
57
+ if dry_run:
58
+ preview = governed(gov.undo_apply(undo_id=undo_id, dry_run=True, target=target))
59
+ dry_run_print(
60
+ operation="undo_apply",
61
+ api_call=f"inverse: {preview.get('wouldApply', {}).get('tool', '?')}",
62
+ parameters=preview.get("wouldApply", {}).get("params", {}),
63
+ )
64
+ return
65
+ double_confirm("apply undo", undo_id)
66
+ console.print_json(json.dumps(governed(gov.undo_apply(undo_id=undo_id, target=target))))
bind9_aiops/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Local BIND 9 target configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from bind9_aiops.governance.paths import ops_home
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TargetConfig:
16
+ name: str
17
+ server: str = "127.0.0.1"
18
+ dns_port: int = 53
19
+ rndc_config: str | None = None
20
+ named_config: str | None = None
21
+ timeout: float = 10.0
22
+ rndc_bin: str = "rndc"
23
+ checkconf_bin: str = "named-checkconf"
24
+ dig_bin: str = "dig"
25
+
26
+ def __post_init__(self) -> None:
27
+ if not self.name or not self.server or self.server.startswith("-"):
28
+ raise ValueError("name and server are required; server cannot start with '-'.")
29
+ if not 1 <= self.dns_port <= 65535 or self.timeout <= 0:
30
+ raise ValueError("dns_port must be 1–65535 and timeout must be positive.")
31
+ for binary in (self.rndc_bin, self.checkconf_bin, self.dig_bin):
32
+ if not binary or binary.startswith("-"):
33
+ raise ValueError("Command binaries must be configured by the operator.")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AppConfig:
38
+ targets: tuple[TargetConfig, ...] = ()
39
+
40
+ def get_target(self, name: str | None = None) -> TargetConfig:
41
+ if name is None:
42
+ if not self.targets:
43
+ raise ValueError("No BIND 9 target configured. Run 'bind9-aiops init'.")
44
+ return self.targets[0]
45
+ for target in self.targets:
46
+ if target.name == name:
47
+ return target
48
+ raise KeyError(f"Unknown BIND 9 target '{name}'.")
49
+
50
+
51
+ def load_config(path: Path | None = None) -> AppConfig:
52
+ path = path or Path(os.environ.get("BIND9_AIOPS_CONFIG", ops_home() / "config.yaml"))
53
+ if not path.is_file():
54
+ raise FileNotFoundError(f"No BIND 9 config at {path}. Run 'bind9-aiops init'.")
55
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
56
+ if not isinstance(raw, dict) or not isinstance(raw.get("targets"), list):
57
+ raise ValueError("BIND 9 config must have a targets list.")
58
+ return AppConfig(tuple(TargetConfig(**entry) for entry in raw["targets"]))
@@ -0,0 +1,119 @@
1
+ """Bounded execution of official BIND 9 administrative commands; no shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from typing import Any
8
+
9
+ from bind9_aiops.config import AppConfig, TargetConfig, load_config
10
+
11
+ _DOMAIN = re.compile(r"^(?:[A-Za-z0-9_*-]{1,63}\.)*[A-Za-z0-9_*-]{1,63}\.?$")
12
+ _RRTYPES = {"A", "AAAA", "CNAME", "DNSKEY", "DS", "MX", "NS", "RRSIG", "SOA", "TXT"}
13
+ _OUTPUT_MAX = 12000
14
+
15
+
16
+ class BindCommandError(Exception):
17
+ pass
18
+
19
+
20
+ class BindWriteUncertainError(BindCommandError):
21
+ """The rndc write may have reached named before the response was lost."""
22
+
23
+
24
+ def domain(value: str) -> str:
25
+ if not _DOMAIN.fullmatch(value) or value.startswith("-") or ".." in value:
26
+ raise ValueError("Invalid DNS name; use a domain or host name without command options.")
27
+ return value
28
+
29
+
30
+ class BindConnection:
31
+ def __init__(self, target: TargetConfig, runner: Any = subprocess.run) -> None:
32
+ self.target = target
33
+ self._run = runner
34
+
35
+ def _command(self, argv: list[str], *, write: bool = False) -> dict:
36
+ try:
37
+ result = self._run(
38
+ argv, capture_output=True, text=True, timeout=self.target.timeout, check=False
39
+ )
40
+ except FileNotFoundError as exc:
41
+ raise BindCommandError(
42
+ f"Missing BIND command '{argv[0]}'; install bind9 utilities or configure its path."
43
+ ) from exc
44
+ except subprocess.TimeoutExpired as exc:
45
+ error_type = BindWriteUncertainError if write else BindCommandError
46
+ raise error_type(
47
+ f"BIND command '{argv[0]}' timed out after {self.target.timeout:g}s."
48
+ ) from exc
49
+ if result.returncode != 0:
50
+ detail = (result.stderr or result.stdout or "unknown error")[:800]
51
+ raise BindCommandError(
52
+ f"BIND command '{argv[0]}' failed (exit {result.returncode}): {detail}"
53
+ )
54
+ output = result.stdout or ""
55
+ return {
56
+ "output": output[:_OUTPUT_MAX] + ("…" if len(output) > _OUTPUT_MAX else ""),
57
+ "truncated": len(output) > _OUTPUT_MAX,
58
+ }
59
+
60
+ def rndc(self, *arguments: str) -> dict:
61
+ argv = [self.target.rndc_bin]
62
+ if self.target.rndc_config:
63
+ argv.extend(["-c", self.target.rndc_config])
64
+ argv.extend(arguments)
65
+ return self._command(argv)
66
+
67
+ def rndc_write(self, *arguments: str) -> dict:
68
+ argv = [self.target.rndc_bin]
69
+ if self.target.rndc_config:
70
+ argv.extend(["-c", self.target.rndc_config])
71
+ argv.extend(arguments)
72
+ return self._command(argv, write=True)
73
+
74
+ def check_config(self) -> dict:
75
+ argv = [self.target.checkconf_bin, "-z"]
76
+ if self.target.named_config:
77
+ argv.append(self.target.named_config)
78
+ return self._command(argv)
79
+
80
+ def query(self, name: str, rrtype: str = "SOA", server: str | None = None) -> dict:
81
+ name = domain(name)
82
+ rrtype = rrtype.upper()
83
+ if rrtype not in _RRTYPES:
84
+ raise ValueError(f"Unsupported RR type '{rrtype}'.")
85
+ server = server or self.target.server
86
+ if not server or server.startswith("-") or any(c.isspace() for c in server):
87
+ raise ValueError("Invalid DNS server address.")
88
+ argv = [
89
+ self.target.dig_bin,
90
+ f"@{server}",
91
+ "-p",
92
+ str(self.target.dns_port),
93
+ name,
94
+ rrtype,
95
+ "+tries=1",
96
+ "+time=2",
97
+ "+noall",
98
+ "+comments",
99
+ "+answer",
100
+ ]
101
+ result = self._command(argv)
102
+ match = re.search(r"\bstatus:\s*([A-Z]+)", result["output"])
103
+ result.update(
104
+ {
105
+ "name": name,
106
+ "rrtype": rrtype,
107
+ "server": server,
108
+ "rcode": match.group(1) if match else None,
109
+ }
110
+ )
111
+ return result
112
+
113
+
114
+ class ConnectionManager:
115
+ def __init__(self, config: AppConfig | None = None) -> None:
116
+ self.config = config or load_config()
117
+
118
+ def connect(self, target: str | None = None) -> BindConnection:
119
+ return BindConnection(self.config.get_target(target))
@@ -0,0 +1,50 @@
1
+ """Governance harness for bind9-aiops — audit, policy, budget, undo, sanitize.
2
+
3
+ A self-contained, vendored governance layer. bind9-aiops has NO dependency on
4
+ any external skill family — this package is its own copy of the harness:
5
+
6
+ - ``@governed_tool`` — mandatory decorator on every MCP tool: policy pre-check,
7
+ token/runaway budget guard, graduated-autonomy risk-tier gate, audit logging,
8
+ and undo-token recording.
9
+ - unified SQLite audit log under ``~/.bind9-aiops/`` (override with
10
+ ``BIND9_AIOPS_HOME``).
11
+ - ``sanitize`` — output hygiene (encoding-level defense) for API-returned text.
12
+
13
+ State lives under ``ops_home()`` (default ``~/.bind9-aiops``).
14
+ """
15
+
16
+ from bind9_aiops.governance.audit import AuditEngine, get_engine
17
+ from bind9_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
18
+ from bind9_aiops.governance.decorators import PolicyDenied, governed_tool
19
+ from bind9_aiops.governance.outcome import (
20
+ UNKNOWN_FIELD,
21
+ capture_prior_state,
22
+ is_unknown,
23
+ mark_unknown,
24
+ )
25
+ from bind9_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
26
+ from bind9_aiops.governance.policy import get_policy_engine
27
+ from bind9_aiops.governance.sanitize import opt_str, sanitize
28
+ from bind9_aiops.governance.undo import UndoStore, get_undo_store
29
+
30
+ __all__ = [
31
+ "governed_tool",
32
+ "sanitize",
33
+ "opt_str",
34
+ "capture_prior_state",
35
+ "mark_unknown",
36
+ "is_unknown",
37
+ "UNKNOWN_FIELD",
38
+ "PolicyDenied",
39
+ "get_engine",
40
+ "AuditEngine",
41
+ "get_policy_engine",
42
+ "get_budget",
43
+ "BudgetTracker",
44
+ "BudgetExceeded",
45
+ "get_undo_store",
46
+ "UndoStore",
47
+ "Pattern",
48
+ "PatternMatch",
49
+ "get_pattern_engine",
50
+ ]