0ba3c09 (main) · assessed 2026-10-18deploy that exits 0, and leaky layering (CLI concerns inside services).apply/destroy and has a spec; deletions are guarded by terrasafe; state is locked by default; no shell=True anywhere.graph_runner, main, cli, analyzer, azure) has 0% test coverage, and lint, type and security gates are configured but not enforced.tgwrap/deploy.py:35-47git clone without --N/A means the repo has no artifacts of that type. That is treated as a deliberate scope decision and does not lower the overall grade. tgwrap is a CLI that operates Terragrunt/Terraform code but contains none itself, so dimension 3 is assessed as IaC orchestration safety.
sys.exit and raise click exceptions, legacy function modules sit alongside the services, and three core modules are orphaned.os.chdir, os.environ).tf alias. Azure coupling is confined to the inspect and telemetry paths. Stage names are hardcoded.wrapper.py, examples/ and docs/, none of which exist).uv export)azure-identity → msal and requests. Some counts include aliases of the same advisory.External processes: terragrunt, tf (tenv alias), git, rsync, az. External HTTP: PyPI on every run, Azure Monitor DCE, ARM, Microsoft Graph. Every command is built as a string and passed through shlex.split, never assembled as an argv list.
Click a dimension to expand it. File references use path:line at commit 0ba3c09.
Justification. The 2025 refactor added a façade (main.TgWrap) and focused services with constructor injection (AnalyzerService receives the graph runner, the command constructor and the Azure service). The boundaries don't fully hold: services terminate the process and depend on the CLI framework, and two generations of code live side by side.
tgwrap/main.py:37-66).TgCommandConstructor separates CommandConfig, CommandComponents and assembly, which makes it testable (74% coverage).sys.exit: 19 calls across 9 modules, e.g. services/graph_runner.py:91,131, services/deployer.py:358,363,570, services/azure.py:135, services/analyzer.py:286. That makes them impossible to compose or unit-test without patching.click: services/azure.py:36-91 and services/graph_runner.py:174,177 raise click.ClickException.analyze.py and deploy.py (free functions, each creating its own Printer) are wrapped by the services rather than absorbed into them.core/config.py (TgWrapConfig), core/logging.py (StructuredLogger) and core/resource_management.py are never imported. Of 10 exception classes in core/exceptions.py, only DeploymentError is used.PLANFILE_NAME, TG_FILE and MINIMUM_TG_VERSION are defined in main.py:30-35, command_constructor.py:115-119 and core/config.py:22-24.core/exceptions.py). Services raise; only cli.py maps exceptions to exit codes and messages.core/* modules, or wire TgWrapConfig in as the single source for constants and env config. Don't leave both.deploy.run_sync and analyze.* into their services (or a domain/ package), passing in the shared Printer.Printer or logger protocol into services instead of having them construct one.Justification. The basics are in place: uv with a lockfile, Python ≥3.12, yaml.safe_load everywhere, timeouts on every requests call, and no shell=True. But the configured strictness (strict mypy, broad ruff rule set) isn't met, and several latent bugs come from global state and string handling.
--working-dir {working_dir} is unquoted (command_constructor.py:590, services/graph_runner.py:151), as are the rsync paths (deploy.py:118-121) and git clone {repo} {target_dir} (services/deployer.py:718). Paths containing spaces break, and the repo value goes to git without a -- separator.AzureInspector._role_definitions = {} at class level (inspector.py:46) is mutated in place (inspector.py:155), so all instances share it.os.chdir in services/graph_runner.py:299; os.environ mutated for TG_SOURCE and TG_INPUTS_DEBUG (command_constructor.py:153,475,544)._is_external_dependency uses startswith without a path separator (services/graph_runner.py:475), so /repo/app-shared counts as "inside" /repo/app.group + module/100 (services/graph_runner.py:108) collides once a group has 100 or more modules.ruff format --check.except Exception is always annotated, and re-raises in verbose mode, which helps debugging.list[str] argv everywhere (drop shlex.split on composed strings). Add -- before positional git and rsync arguments.AzureInspector into __init__. Move the role-definition fetch out of the constructor and make it lazy.os.chdir with os.walk(root_dir) plus relpath. Pass env to subprocess.run(env=...) instead of mutating os.environ.os.path.commonpath for the containment check. Represent steps as a (group, module) tuple.ruff format and ruff check --fix once, then ratchet mypy per module (start with services/).Justification. The repo has no .tf or .hcl of its own, which is by design, so this dimension looks at how safely tgwrap drives IaC. The safety-relevant defaults are good and documented in OpenSpec. The main weakness is how module failure is detected.
--auto-approve is restricted to apply/destroy (command_constructor.py:47,586; main.py:180-181) and backed by openspec/specs/terragrunt-command-flags/spec.md.-lock=false only for init/plan with a warning (command_constructor.py:518-529), and the minimum Terragrunt version 0.88.0 is enforced.TERRASAFE_ALLOW_DELETION, .tf.disabled detection) plus a drift score (major×10 + medium + minor/10) make plan risk quantifiable.destroy walks the graph backwards, and --start-at-step lets an operator resume a partially failed run."error" appears anywhere in stderr (services/graph_runner.py:464). Warnings that mention "error" stop a step-by-step apply, while a non-zero exit with no "error" text is still caught by the return-code check.TG_RO_COMMANDS lists state and force-unlock as read-only, and import appears in both lists (command_constructor.py:16-45). Today this is documentation only, but it will mislead anyone who later gates on these lists.is_deletion_in_disabled_file walks the whole CWD tree once per deletion and builds its regex without re.escape (analyze.py:365-384).returncode alone. If text heuristics are still needed, match explicit Terragrunt error prefixes.TG_RO_COMMANDS..tf.disabled file scan once per module and escape identifiers.No Kubernetes manifests, Helm charts or operators. That fits a developer and CI CLI. No grade given, and no impact on the overall grade.
Justification. tgwrap mostly runs inside CI pipelines, so what matters operationally is correct exit codes, predictable network behaviour and diagnosable output. Telemetry and resumability are strengths. Silent failure paths and missing retries are not.
run_sync prints an error and returns normally when rsync is missing or the source path doesn't exist (deploy.py:35-47). deploy then writes version.hcl and the process exits 0.run (cli.py:29-62, 5 s timeout). OUTDATED_IGNORE only suppresses the output, not the request, which adds latency and egress in restricted runners.Printer output to stdout/stderr. The structured, secret-masking StructuredLogger exists but is unused, and there are no correlation or run IDs.requests (DCE, ARM, Graph). One transient 429 or 5xx loses the telemetry or fails the inspection.services/azure.py:137-197).--start-at-step) and --continue-on-error.run_sync raise DeploymentError, and map it to a non-zero exit in the CLI.OUTDATED_IGNORE or CI is set, or cache the result for 24 h.requests.Session with urllib3.Retry (backoff, respecting Retry-After) for Azure calls.StructuredLogger with a --log-format json option for CI, or delete it.Justification. The test suite is healthy but narrow: it covers command building, security validation and sync semantics, and leaves the orchestration core untested. The quality tooling is all declared and none of it is enforced.
services/graph_runner.py (239 stmts), main.py (218), cli.py (214), services/analyzer.py (129) and services/azure.py (92). These carry the highest-risk logic: group ordering, failure detection, exit codes..forgejo/workflows/ci.yml) runs only pytest and uv build: no lint, types, bandit, coverage threshold or dependency audit. It also installs uv unpinned via pip install uv..pre-commit-config.yaml:17-56, while pytest and uv build run on every commit, which is slow and catches the wrong things.[project.optional-dependencies].dev (ruff, pylint, mypy, bandit, …) and [dependency-groups].dev (pytest only), so a plain uv sync leaves out the linters. It also includes an unexplained ruv package.bandit-report.json (from 2025-11) is committed, and the examples/ fixtures that AGENTS.md calls for don't exist.monkeypatch instead of real binaries. Security validation is covered at 95%.ruff check, ruff format --check, bandit -ll, pip-audit (or uv audit), and pytest --cov --cov-fail-under=30, ratcheting the threshold up.subprocess.run and a small DOT fixture: grouping, include/exclude globs, external-dependency filtering, stop-on-failure exit code.click.testing.CliRunner covering exit-code mapping and the --auto-approve matrix.[dependency-groups].dev, drop ruv/pbr/safety if unused, and re-enable the pre-commit linters.tf alias (README "Terraform vs OpenTofu").services/azure.py, inspector.py and the template, and is opt-in via --data-collection-endpoint or inspect.STAGES is hardcoded (core/constants.py: global/sbx/dev/qas/run/tst/acc/prd), which couples the tool to one organisation's naming.Recommendations: make stages configurable (env or config file), and put telemetry behind a small ResultSink protocol with a DCE implementation.
AGENTS.md:7,14-15,30 and README.md:436-444 reference core/wrapper.py, examples/ and docs/, none of which exist. AI agents following AGENTS.md will be misled.CHANGELOG.md stops at 1.0.9 while pyproject.toml is at 1.0.10, and there are no git tags.feature/domain-cleaning, feature/follow-rsync-semantics, fix/auto_approve)._assemble_final_command (command_constructor.py:603-662) and an unused COMMAND_TEMPLATES dict.See the risk register for the prioritised list.
| Area | Observation | |
|---|---|---|
| Identity | DefaultAzureCredential supports MI, workload identity and CLI; user-assigned MI is configurable (inspector.py:139) | good |
| Token scope | Per-resource scopes (Graph, ARM, monitor.azure.com//.default). Tokens are never printed; the verbose payload omits headers | good |
| Auth paths | DCE posting shells out to az account get-access-token (services/azure.py:21-94) while inspect uses the SDK credential. Two failure modes, and the DCE path needs the az CLI installed | med |
| Resilience | Raw requests without retry; 429/Retry-After not honoured; no nextLink / @odata.nextLink pagination in the inspector | med |
| API lifecycle | The inspector template pins old api-versions: 2014-04-01, 2018-06-01, 2019-09-01, 2021-* | low |
| Error disclosure | On failure, the full az account show JSON (tenant, user) is echoed into the exception (services/azure.py:61-73), and it ends up in CI logs | low |
| Governance | inspect checks naming conventions, existence and RBAC assignments against inspect-config.yml, which is a useful lightweight conformance check | good |
DefaultAzureCredential().get_token("https://monitor.azure.com//.default") and drop the az subprocess, so there's one auth path that works with workload identity.azure-monitor-ingestion LogsIngestionClient instead of hand-rolled POSTs. It gives you retries, batching and size limits.Session with Retry(total=5, backoff_factor=1, status_forcelist=[429,500,502,503,504], respect_retry_after_header=True).nextLink when listing. Bump api-versions to current GA and record them in one table.Rows are likelihood, columns are impact on infrastructure change safety or CI reliability.
Effort: S ≤ ½ day, M ≤ 2 days, L > 2 days.
| ID | Sev | Risk | Evidence | Fix | Effort |
|---|---|---|---|---|---|
| R1 | high | deploy or sync exits 0 when rsync is missing or the source path doesn't exist, so CI shows green with nothing deployed | tgwrap/deploy.py:35-47 | Raise DeploymentError; map it to exit 1 in the CLI; add a test that asserts the exception | S |
| R2 | high | 34 known advisories in locked transitive deps (pyjwt 2.12.1, urllib3 2.6.3, cryptography 46.0.7, idna 3.11) | uv.lock; pip-audit | uv lock --upgrade-package pyjwt --upgrade-package urllib3 --upgrade-package cryptography --upgrade-package idna; add an audit job to CI | S |
| R3 | med | argv built from unquoted strings: paths with spaces break; git clone / git switch / rsync lack a -- separator (option injection from manifest values) | command_constructor.py:590, services/graph_runner.py:151, deploy.py:118-136, services/deployer.py:692,718 | Build list[str]; insert --; validate that git_repository doesn't start with - | M |
| R4 | med | False-positive failure detection when stderr contains "error" | services/graph_runner.py:459-469 | Decide on return code; keep text only for the skip detection | S |
| R5 | med | External-dependency check uses a string prefix, so sibling dirs with a shared prefix are treated as internal | services/graph_runner.py:471-477 | os.path.commonpath([a, b]) == a | S |
| R6 | med | Class-level mutable dict shared across AzureInspector instances; network I/O in the constructor | inspector.py:46,150-155 | Initialise in __init__; lazy-load role definitions | S |
| R7 | med | Dead modules suggest capabilities (config, structured logging, timeouts) that don't exist | core/config.py, core/logging.py, core/resource_management.py | Delete them, or wire them in deliberately | S |
| R8 | med | Services call sys.exit or raise click errors, so they can't be reused or tested in isolation | 19 sys.exit calls in 9 modules | Exception hierarchy plus a single exit mapping in cli.py | M |
| R9 | med | Quality gates not enforced (CI runs pytest only; pre-commit linters disabled); orchestration core at 0% coverage | .forgejo/workflows/ci.yml, .pre-commit-config.yaml:17-56 | Add lint/type/audit/coverage jobs; write graph-runner and CLI tests | M–L |
| R10 | low | PyPI call on every run, even with OUTDATED_IGNORE | cli.py:29-62, cli.py:334 | Return early when OUTDATED_IGNORE/CI is set; cache the result | S |
| R11 | low | Process-global side effects: os.chdir, os.environ mutation and deletion of TG_SOURCE | services/graph_runner.py:298-327, command_constructor.py:153,475,544 | Pass cwd= / env= to subprocess | S |
| R12 | low | Step number collides when a group has 100 or more modules | services/graph_runner.py:108 | Use a (group, idx) tuple; parse --start-at-step g.m | S |
| R13 | low | Docs/agent guidance drift; CHANGELOG lags; no release tags; stale bandit-report.json committed | AGENTS.md, README.md:436-444, CHANGELOG.md | Fix the references; tag releases; remove the report from git | S |
| R14 | low | Azure calls: no retry or pagination; old api-versions; two auth paths | inspector.py:190-214, services/azure.py, template | See the Azure section | M |
| R15 | low | Command classification wrong (state and force-unlock marked RO; import listed twice) | command_constructor.py:16-45 | Correct the lists and add an invariant test | S |
| R16 | low | The input validator rejects legitimate args ($, {}, \, .. in working dir) even though execution is shell-less, so the restriction buys little | core/security.py:13-18,59-71 | Document the threat model; allow -var JSON and relative .. paths (argv lists remove the injection risk) | S |
| R17 | low | The .tf.disabled scan walks the whole tree once per deletion; regex not escaped | analyze.py:365-384 | Scan once, cache, re.escape | S |
./reports/assessments/ held no earlier reports, so there are no prior grades to compare. This report sets the baseline. The table below is ready for the next run, and a machine-readable score block is embedded in this file (#assessment-data) so future runs can parse it.| Dimension | 2026-10-18 | Next run | Δ |
|---|---|---|---|
| 1 Architecture | C | — | baseline |
| 2 Python quality | C | — | baseline |
| 3 IaC orchestration safety | B | — | baseline |
| 4 Kubernetes | N/A | — | — |
| 5 Operational excellence | C | — | baseline |
| 6 Azure | C | — | baseline |
| 7 AWS | N/A | — | — |
| 8 Portability | B | — | baseline |
| 9 Testing & QA | D | — | baseline |
| 10 Debt & risk | C | — | baseline |
| Overall | C | — | baseline |
tf.core/security.py added, and probably the now-orphaned core/config/logging modules too.services/*); migration from GitLab to Codeberg/Forgejo CI.run_sync exit behaviour.Narrative: the codebase is moving from a monolithic script to a layered tool, and its change process is maturing (OpenSpec, PRs, detailed changelog). Quality automation hasn't kept pace with the refactor.
run_sync raise on missing rsync or source, and add a test.uv lock --upgrade-package for pyjwt, urllib3, cryptography and idna; add pip-audit to CI.commonpath containment.OUTDATED_IGNORE or CI is set.ruff check and ruff format --check (run ruff --fix and format once first). Pin the uv version.bandit-report.json; add 1.0.10 to the CHANGELOG; tag releases.sys.exit or import click; one exit-code map in cli.py.env= and cwd= instead of global mutation.GraphRunner (DOT fixture plus fake subprocess) and CliRunner tests. Target 60% coverage with a CI ratchet.core/config, logging and resource_management; unify constants.services/.analyze.py/deploy.py into domain modules, making the service layer the single API (and a possible library entry point).--log-format json option for CI log pipelines.examples/ Terragrunt tree in a container (Tofu with a local backend).0ba3c09, excluding .opencode/node_modules (vendored tooling, not part of the product).PYTHONPATH=. uv run pytest --cov=tgwrap; ruff check --statistics and ruff format --check (repo config); pylint tgwrap --disable=C0301; mypy tgwrap --ignore-missing-imports (repo strict config); bandit -r tgwrap; pip-audit on uv export (vulnerability DB as of 2026-10-18).git log only.