Adr Author
MicrosoftOfficialPublished by Microsoft
- Skill
- Content generator
- Software development
- MIT
- Medium risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The adr-author skill provides a phase-gated workflow for creating and maintaining Architecture Decision Records (ADRs). It guides users through Frame, Decide, and Govern phases to ensure architectural decisions are documented with consistent rationale, consequences, and lineage. The skill supports multiple entry modes, including interactive capture, planner handoffs, and template adoption, while enforcing schema validation and automated lineage tracking for project documentation.
What it does
- Guides users through Frame, Decide, and Govern phases for ADR creation.
- Supports MADR v4.0.0 and Y-Statement output templates.
- Automates lineage tracking and supersession rules for ADRs.
- Validates ADR frontmatter and configuration against defined schemas.
- Integrates with architecture-diagrams for visual documentation.
- Scans content for sensitive information before writing files.
When to use it
- When you need to document architectural decisions in a standardized format.
- When transitioning from an existing ADR template to a new project structure.
- When you need to link a new decision to a previous one via supersession.
- When you need to evaluate Architecturally Significant Requirement (ASR) triggers.
Advantages
- Enforces consistent documentation structure across a project.
- Automates the management of ADR lineage and status transitions.
- Provides clear gates for decision-making and rationale capture.
- Includes built-in validation for frontmatter and sensitive content.
Usage examples
- “I need to start a new ADR for our database migration.”
- The agent initiates a capture session, prompting for scope, decision-makers, and drivers to begin the Frame phase.
- “Adopt my existing ADR template for this project.”
- The agent enters adopt-template mode, ingests your template, normalizes it, and generates the first ADR and configuration file.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/microsoft/adr-author/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip adr-author.zip -d .agents/skills
Documentation by Microsoft
adr-author
Overview
This skill encodes the per-phase authoring conventions for Architecture Decision Records consumed by the ADR Creator agent. It also supports direct invocation when no ADR Creator state file exists. Direct callers first run the session recovery and bootstrap protocol from references/adr-identity.md: resolve or create .copilot-tracking/adr-plans/{projectSlug}/state.json, confirm entryMode, projectSlug, and outputTemplate, then continue at the phase recorded in state. It supports three entry modes and two output templates and converges all of them at the Govern phase, where the final ADR file is written and lineage is updated atomically.
Entry modes (state.entryMode):
capture — Interactive authoring driven by user answers to Frame and Decide questions.
from-planner-handoff — Entry from an upstream planner (Security, RAI, SSSC) with pre-populated Frame fields. Frame still requires user confirmation before exit.
adopt-template — One-time setup mode that ingests a project's pre-existing ADR template and emits both the first ADR and a committed
.adr-config.yml.
Output templates (state.outputTemplate):
y-statement — Compact Y-Statement-shaped ADR for low-stakes or reversible decisions. Compressed Frame; ASR triggers optional.
madr-v4 — Long-form MADR v4.0.0 ADR for architecturally significant decisions. ASR trigger evaluation required during Frame.
Entry mode and output template are independent: a from-planner-handoff session can target either y-statement or madr-v4, and a capture session can do the same. The adopt-template mode produces output shaped by the user's normalized template.
Lifecycle at a glance:
| Mode | Phase sequence | Output |
|---|---|---|
capture | Frame → Decide → Govern | Shaped by outputTemplate (y-statement or madr-v4) |
from-planner-handoff | Frame (confirm pre-populated) → Decide → Govern | Shaped by outputTemplate (y-statement or madr-v4) |
adopt-template | Ingest → Normalize → Derive Questions → Fill → Govern | First ADR + .adr-config.yml per the BYO contract |
The state machine, hard exit gates, autonomy tiers (manual, partial, full), and the canonical state.json schema are defined in references/adr-identity.md. This skill provides the authoring activities and artifact contracts; it does not redefine the state machine.
Frame
Activities:
Scope — capture the decision in one or two sentences; bound it to a single project.
Decision-makers — record
deciders,consulted,informed(RACI-aligned). Prefer a role or team handle over a personal name, and never record personal contact details, secrets, credentials, or third-party or customer PII in any ADR field.Drivers — list decision drivers (functional needs, business goals).
Constraints — list non-negotiables (regulatory, platform, contractual, time).
ASR trigger evaluation — required when
state.outputTemplate == 'madr-v4'. Evaluate triggers against the rubric inadr-standards.instructions.mdand record results instate.asrTriggers[]. Defer the rubric and full taxonomy to that file and toreferences/asr-trigger-taxonomy.md.Diagram-format prompt — when
state.userPreferences.diagramFormatis unset, check the architecture-diagrams root state at.copilot-tracking/architecture-diagrams/state.json; if that file providesuserPreferences.diagramFormat, use it. Otherwise ask the user forasciiormermaid, persist the answer tostate.userPreferences.diagramFormat, and persist it to the architecture-diagrams root state for standalone reuse. When a caller, handoff, or existing ADR state already providesstate.userPreferences.diagramFormat, treat that value as authoritative and do not ask again. Required before Frame can exit.
Hard exit gate (restated from references/adr-identity.md):
The Frame phase cannot advance without all of the following recorded: scope statement, deciders list, decision drivers, ASR triggers determination (when
outputTemplate == 'madr-v4'), anduserPreferences.diagramFormat. The user must confirm the Frame summary before advancing.
Output artifacts:
Frame section of the in-progress ADR draft (working draft only; not yet written to disk).
Updated
state.jsonfields:scope,deciders,consulted,informed,drivers,constraints,asrTriggers(whenoutputTemplate == 'madr-v4'),userPreferences.diagramFormat.
Decide
Activities:
Option enumeration — at least two considered options. A single-option ADR is rejected at this gate.
Evaluation criteria — score each option against the drivers and constraints captured in Frame.
Decision outcome selection — name the chosen option and articulate the rationale.
Consequences — document positive, negative, and neutral consequences. Negative consequences are not optional; an ADR with no documented downside is rejected at this gate.
Y-Statement assembly (when state.outputTemplate == 'y-statement'):
Compose the chosen option using the verbatim six-slot formula in
templates/y-statement.md:In the context of (USE CASE), facing (CONCERN), we decided for (OPTION) and against (ALTERNATIVES), to achieve (QUALITY), accepting (DOWNSIDE).
The Y-Statement is the entire Decide output for the
y-statementtemplate. The full MADR options table is omitted.
Hard exit gate:
The Decide phase cannot advance without at least two considered options, the chosen option, and the decision rationale recorded. The user must confirm the Decide summary before advancing.
Govern
This phase converges all three entry modes and is the only phase that writes ADR files to disk.
Activities:
MADR v4 frontmatter assembly — render the ADR frontmatter from
templates/madr-v4.md. The template is reproduced verbatim from MADR v4.0.0 (CC0); seereferences/standards-excerpts.mdfor attribution. Mergetemplates/madr-v4-frontmatter-overlay.mdon top to inject hve-core extension fields (id,deciders,tags,supersedes,superseded-by,related,asr_triggers) without modifying the verbatim upstream template (GP-17).Diagram render — based on
state.userPreferences.diagramFormat, embed the diagram body from eithertemplates/diagram-ascii.mdortemplates/diagram-mermaid.md. Skill callers do not branch on platform; the template selection is purely data-driven. When the diagram is derived from infrastructure source files (Terraform, Bicep, ARM), invoke thearchitecture-diagramsskill with the authoritative format recorded instate.userPreferences.diagramFormat, and embed that output in place of the scaffold fragment. Standalone diagram generation uses the architecture-diagrams root state contract rather than ADR state.Lineage validation — apply the six supersession rules summarized below; full text is in
references/lineage-rules.md.Frontmatter validation — invoke
scripts/validate_frontmatter.pyagainst the staged ADR file. The script returns a non-zero exit code on schema or enum violations and is the single authority for frontmatter shape.Lineage allocator — invoke
scripts/update_lineage.pyto mutate.adr-config.yml. The allocator is the only writer oflast_decision_id. Manual edits tolast_decision_idare forbidden.Final write — write the ADR to
docs/planning/adrs/{NNNN}-{slug}.md. The path is derived from the allocator-issuedNNNNand the slugified ADR title.Handoff trigger — emit the dual-format (ADO + GitHub) work items per
adr-handoff.instructions.md. This skill stops at handoff emission; routing is the agent's responsibility.
Govern uses the autonomy and disclaimer banners required by references/adr-identity.md and the disclaimer-language skill. Do not duplicate those texts here; load them at runtime.
Supersession Lineage Rules (Summary)
Brief enumeration. Full normative text and edge cases live in references/lineage-rules.md.
supersedesandsuperseded-byare each a scalar string ornull.Single-parent supersession — a given ADR has at most one
superseded-by.Status transition — the superseding ADR's
statusbecomesaccepted; the superseded ADR'sstatusbecomessuperseded.Lineage updates are atomic — both ADR files MUST be updated in the same Govern phase.
The lineage allocator (
scripts/update_lineage.py) is the single writer oflast_decision_idin.adr-config.yml; manual edits are forbidden.
Status Taxonomy
ADR status is one of six closed values. Full definitions and lifecycle transitions live in adr-standards.instructions.md.
proposed— under active drafting; not yet decided.accepted— decision adopted; current authority for the scope.rejected— considered and declined; retained for historical context.deprecated— no longer recommended but not yet replaced.superseded— replaced by a newer ADR via the lineage rules below.withdrawn— proposal withdrawn before decision.
ASR Trigger Catalog (Summary)
ASR (Architecturally Significant Requirement) triggers are evaluated only when state.outputTemplate == 'madr-v4'. The closed enum has eight values:
costperformancesecuritycomplianceavailabilityscalabilitymaintainabilityevolvability
The trigger rubric, evaluation prompts, and example mappings are defined in adr-standards.instructions.md and elaborated in references/asr-trigger-taxonomy.md. Do not introduce trigger values outside the closed enum.
Adopt-Template Lifecycle
Five-step pointer. Full lifecycle, including GP-13 (the .adr-config.yml schema and the 2-layer config resolution), lives in adr-byo-template.instructions.md.
Ingest — accept the user's existing ADR template file or template directory.
Normalize — invoke
scripts/normalize_template.pyto convert the template into the canonical ADR frontmatter and section structure.Derive Questions — generate the Frame and Decide question set from the normalized template's required fields.
Fill — execute the derived questions to populate the first ADR.
Govern — run the standard Govern phase. When
lineage_fieldsare absent from the adopted template, the agent MUST warn the user and require an explicit confirmation before writing; this is the warn-and-confirm Govern behavior referenced inadr-byo-template.instructions.md.
Templates
templates/madr-v4.md— MADR v4.0.0 ADR template (verbatim, CC0). Used by Govern frontmatter assembly whenstate.outputTemplate == 'madr-v4'.templates/y-statement.md— Six-slot Y-Statement formula for Decide assembly whenstate.outputTemplate == 'y-statement'.templates/diagram-ascii.md— ASCII diagram block, selected whenstate.userPreferences.diagramFormat == "ascii".templates/diagram-mermaid.md— Mermaid diagram block, selected whenstate.userPreferences.diagramFormat == "mermaid".
References
references/adr-identity.md— ADR Creator identity, three-phase state machine, six-step per-turn protocol, autonomy tiers, and the canonicalstate.jsonschema. Read it at session start.references/standards-excerpts.md— MADR v4.0.0 verbatim text and CC0 attribution; Y-Statement attribution; status taxonomy.references/lineage-rules.md— Full text of the six supersession rules with edge cases and worked examples.references/asr-trigger-taxonomy.md— Full ASR trigger taxonomy, rubric prompts, and examples for each of the eight enum values.
Scripts
scripts/render_template.py— Renders a template fromtemplates/against a Frame+Decide payload to produce an in-memory ADR draft. Path-traversal guarded: refuses any output path outsidedocs/planning/adrs/.scripts/validate_frontmatter.py— Validates ADR frontmatter against the MADR v4 schema and the closed enums. Returns non-zero on violation. Path-traversal guarded against the same root.scripts/update_lineage.py— Single writer oflast_decision_idin.adr-config.yml. Mutates predecessor ADRs'superseded-byatomically with the new ADR'ssupersedes. Path-traversal guarded.scripts/normalize_template.py— Converts a user-supplied ADR template into the canonical structure used bytemplates/madr-v4.md. Used only by theadopt-templatelifecycle. Path-traversal guarded.scripts/scan_sensitive_content.py: deterministic disclosure-risk scanner accepting file paths or stdin and emitting masked JSON findings. Base behavior detects high-confidence PII.--publicadds internal URL detection.--dataindependently adds structured sensitive-column, connection, credential, sample-row, and international identifier detection.--denylist <path>independently adds case-insensitive literal customer-term detection from a UTF-8 file, resolved through the same allow-root guard as scanned paths and capped by file size, term count, and minimum term length.--allow-root <dir>adds a directory to that guard. The flags combine by union without changing no-flag ADR behavior. Required before durable ADR writes and external or handoff emission.
Scanner report contract
The scanner prints one JSON object to standard output on every terminal path, including error paths, so callers can assert on fields rather than parse prose. The object carries schema_version (scan-sensitive-content-v1), status (completed or error), modes (booleans for public, data, and denylist), denylist_rule_count, findings, and summary with high, warn, and total counts. On status error it also carries error with a stable code and a message. Exit codes are 0 for a completed scan with no high-confidence finding, 1 for a completed scan with at least one high-confidence finding, and 2 for an error. A caller that requires denylist enforcement asserts modes.denylist is true and denylist_rule_count is greater than zero, so an empty or unloaded denylist cannot silently disable the gate.
All scripts treat their working directory as untrusted input. The ADR authoring scripts reject paths that resolve outside the project ADR root. scan_sensitive_content.py scans content that is not an ADR, so it rejects paths that resolve outside its allow roots: this skill's directory, the repository root, the parent directory of each path passed on the command line, and any directory supplied through --allow-root.
Source Attribution
templates/madr-v4.md— reproduced byte-identical from MADR v4.0.0 (tag4.0.0, filetemplate/adr-template.md), released under CC0-1.0. CC0 does not require attribution; it is recorded here for transparency. Upstream typographical anomalies (for example, the unbalanced quotation in thestatus:placeholder) are preserved intentionally to keep the file diff-clean against the upstream release.
Mandatory Load Directives
The ADR Creator agent enforces a phase→section load contract per references/adr-identity.md. Each phase MUST load its section of this skill before executing phase work, and MUST append the section anchor to state.phaseSkillsLoaded:
| Phase | Section anchor | Required phaseSkillsLoaded entry |
|---|---|---|
| Frame | #frame | adr-author#frame |
| Decide | #decide | adr-author#decide |
| Govern | #govern | adr-author#govern |
The agent reads sections of this skill file and records the entry in state.phaseSkillsLoaded before any phase work executes. phaseSkillsLoaded is durable load history, not proof that the guidance is present in the current model context. Within one live context, re-entering a phase whose section was already read does not require reloading. After a cold start, resume, or context summarization, the agent reloads the current phase section even when its entry is recorded.
Files
- ATTRIBUTION.mdAdded by AutoGineer872 B
- LICENSELicence file added by AutoGineer1.1 KiB
- SKILL.md16.4 KiB
- pyproject.toml756 B
- references/adr-identity.md20.2 KiB
- references/asr-trigger-taxonomy.md5.1 KiB
- references/authoring-rubric.md4.2 KiB
- references/lineage-rules.md7.0 KiB
- references/standards-excerpts.md4.1 KiB
- scripts/__init__.pyScript136 B
- scripts/_utils.pyScript2.0 KiB
- scripts/normalize_template.pyScript6.9 KiB
- scripts/render_template.pyScript5.2 KiB
- scripts/scan_sensitive_content.pyScript26.5 KiB
- scripts/update_lineage.pyScript10.3 KiB
- scripts/validate_config.pyScript2.8 KiB
- scripts/validate_frontmatter.pyScript14.7 KiB
- templates/diagram-ascii.md1005 B
- templates/diagram-mermaid.md628 B
- templates/madr-v4-frontmatter-overlay.md1.7 KiB
- templates/madr-v4.md3.2 KiB
- templates/y-statement.md660 B
- tests/__init__.pyScript148 B
- tests/conftest.pyScript3.6 KiB
- tests/corpus/0_frontmatter_minimal46 B
- tests/corpus/1_markdown_anchors64 B
- tests/corpus/2_slug_valid31 B
- tests/corpus/README.md1.0 KiB
- tests/fuzz_harness.pyScript4.2 KiB
- tests/test_normalize_template.pyScript5.5 KiB
- tests/test_render_template.pyScript5.3 KiB
- tests/test_scan_sensitive_content.pyScript31.5 KiB
- tests/test_update_lineage.pyScript10.4 KiB
- tests/test_utils.pyScript3.3 KiB
- tests/test_validate_frontmatter.pyScript15.2 KiB
- uv.lock107.8 KiB
tests/test_scan_sensitive_content.py
# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
"""Tests for `scripts.scan_sensitive_content`.
Covers high-confidence PII and public internal-URL findings, benign ADR prose
true negatives, non-PII credential-shaped text, and the external-sink gating
contract that non-zero exit accompanies any high-confidence finding.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
scan_sensitive_content = pytest.importorskip("scripts.scan_sensitive_content")
def _invoke(args: list[str], capsys: pytest.CaptureFixture[str]) -> tuple[int, dict]:
"""Invoke `scan_sensitive_content.main` and return exit code plus parsed JSON."""
try:
exit_code = int(scan_sensitive_content.main(args) or 0)
except SystemExit as exc:
exit_code = int(exc.code or 0)
out = capsys.readouterr().out
report = json.loads(out) if out.strip() else {}
return exit_code, report
class TestScanHighConfidenceTruePositives:
@pytest.mark.parametrize(
("pii", "category"),
[
("[email protected]", "email_address"),
("425-555-0100", "phone_number"),
("123-45-6789", "national_identifier"),
],
)
def test_given_pii_when_scan_then_high_finding_and_nonzero_exit(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
pii: str,
category: str,
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text(f"Contact detail: {pii}\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
categories = {f["category"] for f in report["findings"]}
assert category in categories
assert report["summary"]["high"] >= 1
# The raw PII must not be echoed back verbatim (redaction contract).
assert pii not in json.dumps(report)
class TestScanSafeNegatives:
@pytest.mark.parametrize(
"benign",
[
"We chose Postgres for ACID guarantees and operational maturity.",
"See https://learn.microsoft.com/azure for guidance.",
"The decision ID is 0007 and supersedes 0003.",
"Latency budget is 200ms at p99 under peak load.",
],
)
def test_given_benign_prose_when_scan_then_no_findings_and_zero_exit(
self,
tmp_path: Path,
benign: str,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text(benign + "\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["summary"]["high"] == 0
@pytest.mark.parametrize(
"provider_key_shape",
[
"-----BEGIN RSA PRIVATE KEY-----",
"ghp_0123456789abcdefghijklmnopqrstuvwxyz",
"AKIAIOSFODNN7EXAMPLE",
"AIzaSyA0123456789abcdefghijklmnopqrstuvwxyz0",
"example slack token placeholder",
"sk-0123456789abcdefghijklmnopqrstuvwxyzABCDEF",
],
)
def test_given_provider_key_shape_when_scan_then_no_findings_and_zero_exit(
self,
tmp_path: Path,
provider_key_shape: str,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text(f"Decision note includes {provider_key_shape} as sample text.\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
class TestScanInternalUrlVisibility:
@pytest.mark.parametrize(
"internal_url",
[
"http://localhost:8080/admin",
"https://10.1.2.3/healthz",
"http://db.corp/status",
],
)
def test_given_internal_url_when_private_then_no_high_and_zero_exit(
self,
tmp_path: Path,
internal_url: str,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text(f"Service runs at {internal_url} today.\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
categories = {f["category"] for f in report["findings"]}
assert "internal_url" not in categories
assert report["summary"]["high"] == 0
@pytest.mark.parametrize(
"internal_url",
[
"http://localhost:8080/admin",
"https://10.1.2.3/healthz",
"http://db.corp/status",
],
)
def test_given_internal_url_when_public_then_high_and_nonzero_exit(
self,
tmp_path: Path,
internal_url: str,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text(f"Service runs at {internal_url} today.\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--public", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
categories = {f["category"] for f in report["findings"]}
assert "internal_url" in categories
assert report["summary"]["high"] >= 1
class TestScanStdin:
def test_given_pii_on_stdin_when_scan_then_nonzero_exit(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
import io
monkeypatch.setattr("sys.stdin", io.StringIO("Contact: [email protected]\n"))
# Act
exit_code, report = _invoke([], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
assert report["summary"]["high"] >= 1
assert report["findings"][0]["source"] == scan_sensitive_content.STDIN_SOURCE
class TestScanDataMode:
@pytest.mark.parametrize(
("content", "category", "confidence"),
[
("columns: [ssn]", "sensitive_column_name", "high"),
("- name: patientId", "sensitive_column_name", "warn"),
("customer_id varchar(40)", None, None),
("Server=db;Initial Catalog=orders;User Id=app;Password=secret", "connection_string", "high"),
("url: jdbc:postgresql://db/orders", "jdbc_odbc_uri", "high"),
("url: postgres://user:secret@db/orders", "db_uri_with_credentials", "high"),
("AccountKey=YWJjZGVmZ2hpamtsbW5vcHFyc3R1", "storage_key", "high"),
("Authorization: Bearer abcdefghijklmnop", "bearer_token", "high"),
("national_insurance: AB123456C", "uk_national_insurance", "warn"),
("sin: 046 454 286", "canadian_sin", "warn"),
("phone: +442079460958", "international_phone", "warn"),
(
"url: https://acct.blob.core.windows.net/c?sp=r&se=2030-01-01&sig=secret",
"sas_token",
"high",
),
],
)
def test_given_data_rule_when_data_mode_then_expected_finding(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
content: str,
category: str | None,
confidence: str | None,
) -> None:
# Arrange
target = tmp_path / "data.txt"
target.write_text(content + "\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
categories = {finding["category"] for finding in report["findings"]}
if category is None:
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert categories == set()
else:
assert category in categories
expected_exit = (
scan_sensitive_content.EXIT_FAILURE if confidence == "high" else scan_sensitive_content.EXIT_SUCCESS
)
assert exit_code == expected_exit
def test_given_sample_table_when_data_mode_then_warns_without_blocking(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "catalog.md"
target.write_text(
"## Sample rows\n\n| id | value |\n|----|-------|\n| 1 | synthetic |\n",
encoding="utf-8",
)
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert any(finding["category"] == "sample_row" for finding in report["findings"])
assert report["summary"]["warn"] >= 1
def test_given_data_only_content_when_default_mode_then_preserves_empty_result(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text("columns: [ssn]\nurl: jdbc:postgresql://db/orders\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
class TestScanDenylist:
def test_given_denylist_when_term_differs_by_case_then_blocks_without_leak(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
denylist = tmp_path / "terms.txt"
denylist.write_text("Contoso-Blue\n\ncontoso-blue\n", encoding="utf-8")
target = tmp_path / "artifact.md"
target.write_text("Tenant: CONTOSO-BLUE\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--denylist", str(denylist), str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
denylist_findings = [finding for finding in report["findings"] if finding["category"] == "denylist_term"]
assert len(denylist_findings) == 1
assert "contoso-blue" not in json.dumps(report).lower()
def test_given_denylist_and_other_modes_when_scanned_then_rules_form_union(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
denylist = tmp_path / "terms.txt"
denylist.write_text("tenant-seven\n", encoding="utf-8")
target = tmp_path / "artifact.md"
target.write_text(
"tenant-seven\ncolumns: [dob]\nhttp://localhost/admin\n",
encoding="utf-8",
)
# Act
_, report = _invoke(
["--public", "--data", "--denylist", str(denylist), str(target)],
capsys,
)
# Assert
categories = {finding["category"] for finding in report["findings"]}
assert {"denylist_term", "sensitive_column_name", "internal_url"} <= categories
@pytest.mark.parametrize("kind", ["missing", "directory", "invalid-utf8"])
def test_given_invalid_denylist_when_scanned_then_returns_error(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
kind: str,
) -> None:
# Arrange
denylist = tmp_path / "terms.txt"
if kind == "directory":
denylist.mkdir()
elif kind == "invalid-utf8":
denylist.write_bytes(b"\xff\xfe")
# Act
exit_code, report = _invoke(
["--allow-root", str(tmp_path), "--denylist", str(denylist)],
capsys,
)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["status"] == scan_sensitive_content.STATUS_ERROR
assert report["error"]["code"] == "denylist_unreadable"
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
class TestScanPerformance:
def test_given_long_benign_input_when_data_mode_then_completes_without_findings(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "large.txt"
target.write_text(("ordinary catalog context " * 20000) + "\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
class TestScanPathTraversal:
@pytest.mark.parametrize(
"adversarial",
[
"../../../etc/passwd",
"..\\..\\..\\Windows\\System32\\config\\SAM",
],
)
def test_given_traversal_path_when_scan_then_exits_error(
self,
adversarial: str,
capsys: pytest.CaptureFixture[str],
) -> None:
# Act
exit_code, report = _invoke([adversarial], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["status"] == scan_sensitive_content.STATUS_ERROR
assert report["error"]["code"] == "path_error"
def _set_stdin(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
"""Replace stdin with an in-memory stream containing ``text``."""
import io
monkeypatch.setattr("sys.stdin", io.StringIO(text))
class _UndecodableStdin:
"""Stdin stub that fails to decode, mirroring a non-UTF-8 pipe."""
def read(self) -> str:
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
class TestReportContract:
def test_given_clean_file_when_scan_then_completed_report_with_mode_attestation(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text("We chose managed identities over shared keys.\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["schema_version"] == scan_sensitive_content.SCHEMA_VERSION
assert report["status"] == scan_sensitive_content.STATUS_COMPLETED
assert report["modes"] == {"public": False, "data": False, "denylist": False}
assert report["denylist_rule_count"] == 0
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
def test_given_same_clean_file_when_data_mode_then_modes_distinguish_reports(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text("We chose managed identities over shared keys.\n", encoding="utf-8")
# Act
default_exit, default_report = _invoke([str(target)], capsys)
data_exit, data_report = _invoke(["--data", str(target)], capsys)
# Assert
assert default_exit == data_exit == scan_sensitive_content.EXIT_SUCCESS
assert default_report["summary"] == data_report["summary"]
assert default_report["modes"]["data"] is False
assert data_report["modes"]["data"] is True
assert default_report["modes"] != data_report["modes"]
def test_given_high_finding_when_scan_then_status_completed_with_exit_one(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text("Contact: [email protected]\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
assert report["status"] == scan_sensitive_content.STATUS_COMPLETED
assert report["summary"]["high"] == 1
def test_given_unreadable_path_when_scan_then_error_status_not_blocked_status(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "not-a-file"
target.mkdir()
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["status"] == scan_sensitive_content.STATUS_ERROR
assert report["error"]["code"] == "read_error"
assert report["findings"] == []
def test_given_undecodable_file_when_scan_then_decode_error_and_exit_two(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "binary.md"
target.write_bytes(b"\xff\xfe\x00binary")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["status"] == scan_sensitive_content.STATUS_ERROR
assert report["error"]["code"] == "decode_error"
def test_given_multiple_paths_when_scan_then_findings_sort_by_source(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
first = tmp_path / "a.md"
second = tmp_path / "b.md"
first.write_text("Contact: [email protected]\n", encoding="utf-8")
second.write_text("Contact: [email protected]\n", encoding="utf-8")
# Act
_, report = _invoke([str(second), str(first)], capsys)
# Assert
sources = [finding["source"] for finding in report["findings"]]
assert sources == sorted(sources)
assert sources == [str(first), str(second)]
class TestStdinTerminalStates:
def test_given_clean_stdin_when_scan_then_completed_and_zero_exit(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
_set_stdin(monkeypatch, "We chose managed identities over shared keys.\n")
# Act
exit_code, report = _invoke([], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["status"] == scan_sensitive_content.STATUS_COMPLETED
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
def test_given_pii_stdin_when_scan_then_completed_and_exit_one(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
_set_stdin(monkeypatch, "Contact: [email protected]\n")
# Act
exit_code, report = _invoke([], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
assert report["status"] == scan_sensitive_content.STATUS_COMPLETED
def test_given_undecodable_stdin_when_scan_then_error_and_exit_two(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
monkeypatch.setattr("sys.stdin", _UndecodableStdin())
# Act
exit_code, report = _invoke([], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["status"] == scan_sensitive_content.STATUS_ERROR
assert report["error"]["code"] == "decode_error"
def test_given_oversized_stdin_when_scan_then_input_too_large(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
monkeypatch.setattr(scan_sensitive_content, "MAX_INPUT_BYTES", 16)
_set_stdin(monkeypatch, "Contact: [email protected] and [email protected]\n")
# Act
exit_code, report = _invoke([], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["error"]["code"] == "input_too_large"
assert report["findings"] == []
class TestInputBounds:
def test_given_oversized_file_when_scan_then_rejected_before_rules_run(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
monkeypatch.setattr(scan_sensitive_content, "MAX_INPUT_BYTES", 16)
target = tmp_path / "adr.md"
target.write_text("Contact: [email protected]\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["error"]["code"] == "input_too_large"
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
def test_given_content_past_line_cap_when_scan_then_not_scanned(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
padding = "a" * (scan_sensitive_content.MAX_LINE_LENGTH + 100)
target = tmp_path / "adr.md"
target.write_text(f"{padding} [email protected]\n", encoding="utf-8")
# Act
exit_code, report = _invoke([str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["summary"]["high"] == 0
def test_given_repeated_credential_segments_when_data_mode_then_scan_completes(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adversarial.txt"
target.write_text("Password=synthetic;User Id=synthetic\n" * 2000, encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert report["status"] == scan_sensitive_content.STATUS_COMPLETED
assert exit_code == scan_sensitive_content.EXIT_FAILURE
assert report["summary"]["high"] >= 1
class TestDenylistConfiguration:
@pytest.mark.parametrize(
("contents", "code"),
[
("", "denylist_empty"),
(" \n\t\n\n", "denylist_empty"),
("ab\n", "denylist_term_too_short"),
("x" * 70000, "denylist_too_large"),
("\n".join(f"term{index:05d}" for index in range(1001)), "denylist_too_large"),
],
# Explicit ids keep the generated test id short. Deriving ids from the
# parameter values embeds the oversized fixtures in PYTEST_CURRENT_TEST,
# which exceeds the 32767-character environment variable limit on Windows.
ids=[
"empty",
"whitespace-only",
"term-too-short",
"file-too-large",
"too-many-terms",
],
)
def test_given_unusable_denylist_when_scanned_then_specific_error_code(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
contents: str,
code: str,
) -> None:
# Arrange
denylist = tmp_path / "terms.txt"
denylist.write_text(contents, encoding="utf-8")
target = tmp_path / "artifact.md"
target.write_text("benign content\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--denylist", str(denylist), str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["status"] == scan_sensitive_content.STATUS_ERROR
assert report["error"]["code"] == code
assert report["summary"] == {"high": 0, "warn": 0, "total": 0}
def test_given_denylist_outside_allow_roots_when_scanned_then_path_error(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
outside = tmp_path / "outside"
outside.mkdir()
denylist = outside / "terms.txt"
denylist.write_text("tenant-seven\n", encoding="utf-8")
scanned = tmp_path / "scanned"
scanned.mkdir()
target = scanned / "artifact.md"
target.write_text("benign content\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--denylist", str(denylist), str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_ERROR
assert report["error"]["code"] == "path_error"
def test_given_usable_denylist_when_scanned_then_rule_count_reported(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
denylist = tmp_path / "terms.txt"
denylist.write_text("tenant-seven\nTENANT-SEVEN\nproject-indigo\n\n", encoding="utf-8")
target = tmp_path / "artifact.md"
target.write_text("benign content\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--denylist", str(denylist), str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert report["modes"]["denylist"] is True
assert report["denylist_rule_count"] == 2
class TestSecretDetection:
SECRET_FIXTURES: tuple[tuple[str, str, str], ...] = (
("private_key_block", "-----BEGIN RSA PRIVATE KEY-----", "PRIVATE KEY"),
("cloud_access_key_id", "AKIAZZ7SYNTHETIC0000", "SYNTHETIC"),
("source_control_token", "ghp_synthetic" + "0" * 27, "synthetic"),
("chat_webhook_token", "xoxb-synthetic-000000", "synthetic"),
(
"web_token",
"eyJsynthetic0000.eyJsynthetic1111.synthetic2222222",
"synthetic1111",
),
("generic_secret_assignment", 'api_key = "synthetic0000000000"', "synthetic0000"),
)
@pytest.mark.parametrize(("category", "payload", "fragment"), SECRET_FIXTURES)
def test_given_secret_when_data_mode_then_blocks_without_preview_leak(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
category: str,
payload: str,
fragment: str,
) -> None:
# Arrange
target = tmp_path / "notes.md"
target.write_text(f"{payload}\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
matched = [finding for finding in report["findings"] if finding["category"] == category]
assert matched, f"expected a {category} finding"
for finding in matched:
assert finding["match"] == scan_sensitive_content.REDACTED_PREVIEW
assert isinstance(finding["length"], int)
assert fragment not in json.dumps(report)
@pytest.mark.parametrize(
"payload",
[
'api_key = "YOUR_API_KEY_HERE"',
"password: xxxxxxxxxxxxxxxx",
"client_secret = <REPLACE_WITH_SECRET>",
"token = CHANGEME_CHANGEME_CH",
],
)
def test_given_placeholder_value_when_data_mode_then_no_finding(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
payload: str,
) -> None:
# Arrange
target = tmp_path / "docs.md"
target.write_text(f"{payload}\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
categories = {finding["category"] for finding in report["findings"]}
assert "generic_secret_assignment" not in categories
def test_given_secret_bearing_categories_when_scanned_then_never_preview_content(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
denylist = tmp_path / "terms.txt"
denylist.write_text("tenant-synthetica\n", encoding="utf-8")
target = tmp_path / "mixed.md"
target.write_text(
"tenant-synthetica\n"
"Server=synthetichost;Initial Catalog=orders;Password=synthetic\n"
"url: postgres://user:syntheticpass@db/orders\n"
"AccountKey=c3ludGhldGljc3ludGhldGljMDAw\n"
"Authorization: Bearer syntheticbearervalue\n",
encoding="utf-8",
)
# Act
exit_code, report = _invoke(["--data", "--denylist", str(denylist), str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_FAILURE
serialized = json.dumps(report)
assert "synthetic" not in serialized
for finding in report["findings"]:
if finding["category"] in scan_sensitive_content.NO_PREVIEW_CATEGORIES:
assert finding["match"] == scan_sensitive_content.REDACTED_PREVIEW
class TestHeuristicNarrowing:
@pytest.mark.parametrize(
("content", "expected"),
[
("Address: see the runbook", False),
("address:", True),
("address: string", True),
("Postal: the mail room forwards these", False),
],
)
def test_given_key_value_line_when_data_mode_then_prose_is_not_a_column(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
content: str,
expected: bool,
) -> None:
# Arrange
target = tmp_path / "schema.md"
target.write_text(content + "\n", encoding="utf-8")
# Act
_, report = _invoke(["--data", str(target)], capsys)
# Assert
categories = {finding["category"] for finding in report["findings"]}
assert ("sensitive_column_name" in categories) is expected
def test_given_table_separator_row_when_data_mode_then_no_sample_finding(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "catalog.md"
target.write_text("## Sample rows\n\n|---|---|\n|:--|--:|\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert not [finding for finding in report["findings"] if finding["category"] == "sample_row"]
def test_given_distant_json_array_when_data_mode_then_sample_context_expired(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "catalog.md"
target.write_text(
"## Sample rows\n" + ("Narrative paragraph about the decision.\n" * 100) + '["unrelated", "array"]\n',
encoding="utf-8",
)
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
assert not [finding for finding in report["findings"] if finding["category"] == "sample_row"]
def test_given_blank_line_after_rows_when_data_mode_then_context_closes(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "catalog.md"
target.write_text(
"## Sample rows\n\n| id | value |\n| 1 | synthetic |\n\n[1, 2, 3]\n",
encoding="utf-8",
)
# Act
_, report = _invoke(["--data", str(target)], capsys)
# Assert
sample_lines = [finding["line"] for finding in report["findings"] if finding["category"] == "sample_row"]
assert sample_lines == [3, 4]
def test_given_url_with_query_when_not_sas_shaped_then_no_finding(
self,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
target = tmp_path / "adr.md"
target.write_text("See https://learn.microsoft.com/azure?view=latest for guidance.\n", encoding="utf-8")
# Act
exit_code, report = _invoke(["--data", str(target)], capsys)
# Assert
assert exit_code == scan_sensitive_content.EXIT_SUCCESS
categories = {finding["category"] for finding in report["findings"]}
assert "sas_token" not in categories
Requirements and compatibility
Written for: GitHub Copilot
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Python
Security
What our scanner noticed· Medium risk
- References to credential files
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Microsoft
- Publisher
- Microsoft (Official)
- Source
- microsoft/hve-core · .github/skills/project-planning/adr-author · commit f425e94
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Microsoft or Microsoft.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download adr-author.zip