Generated banner for Adr Author
Generated logo for Adr Author

Adr Author

MicrosoftOfficialPublished by Microsoft

  • Skill
  • Content generator
  • Software development
  • MIT
  • Medium risk
  • Has scripts

From the author

Authoring skill for Architecture Decision Records (ADRs) supporting capture, from-planner-handoff, and adopt-template entry modes with selectable Y-Statement or MADR v4.0.0 output templates, supersession lineage, and ASR trigger evaluation.
— the description by Microsoft

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

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/microsoft/adr-author/download
  2. Unzip 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:

ModePhase sequenceOutput
captureFrame → Decide → GovernShaped by outputTemplate (y-statement or madr-v4)
from-planner-handoffFrame (confirm pre-populated) → Decide → GovernShaped by outputTemplate (y-statement or madr-v4)
adopt-templateIngest → Normalize → Derive Questions → Fill → GovernFirst 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 in adr-standards.instructions.md and record results in state.asrTriggers[]. Defer the rubric and full taxonomy to that file and to references/asr-trigger-taxonomy.md.

  • Diagram-format prompt — when state.userPreferences.diagramFormat is unset, check the architecture-diagrams root state at .copilot-tracking/architecture-diagrams/state.json; if that file provides userPreferences.diagramFormat, use it. Otherwise ask the user for ascii or mermaid, persist the answer to state.userPreferences.diagramFormat, and persist it to the architecture-diagrams root state for standalone reuse. When a caller, handoff, or existing ADR state already provides state.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'), and userPreferences.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.json fields: scope, deciders, consulted, informed, drivers, constraints, asrTriggers (when outputTemplate == '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-statement template. 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:

  1. MADR v4 frontmatter assembly — render the ADR frontmatter from templates/madr-v4.md. The template is reproduced verbatim from MADR v4.0.0 (CC0); see references/standards-excerpts.md for attribution. Merge templates/madr-v4-frontmatter-overlay.md on top to inject hve-core extension fields (id, deciders, tags, supersedes, superseded-by, related, asr_triggers) without modifying the verbatim upstream template (GP-17).

  2. Diagram render — based on state.userPreferences.diagramFormat, embed the diagram body from either templates/diagram-ascii.md or templates/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 the architecture-diagrams skill with the authoritative format recorded in state.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.

  3. Lineage validation — apply the six supersession rules summarized below; full text is in references/lineage-rules.md.

  4. Frontmatter validation — invoke scripts/validate_frontmatter.py against 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.

  5. Lineage allocator — invoke scripts/update_lineage.py to mutate .adr-config.yml. The allocator is the only writer of last_decision_id. Manual edits to last_decision_id are forbidden.

  6. Final write — write the ADR to docs/planning/adrs/{NNNN}-{slug}.md. The path is derived from the allocator-issued NNNN and the slugified ADR title.

  7. 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.

  1. supersedes and superseded-by are each a scalar string or null.

  2. Single-parent supersession — a given ADR has at most one superseded-by.

  3. Status transition — the superseding ADR's status becomes accepted; the superseded ADR's status becomes superseded.

  4. Lineage updates are atomic — both ADR files MUST be updated in the same Govern phase.

  5. The lineage allocator (scripts/update_lineage.py) is the single writer of last_decision_id in .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:

  • cost

  • performance

  • security

  • compliance

  • availability

  • scalability

  • maintainability

  • evolvability

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.

  1. Ingest — accept the user's existing ADR template file or template directory.

  2. Normalize — invoke scripts/normalize_template.py to convert the template into the canonical ADR frontmatter and section structure.

  3. Derive Questions — generate the Frame and Decide question set from the normalized template's required fields.

  4. Fill — execute the derived questions to populate the first ADR.

  5. Govern — run the standard Govern phase. When lineage_fields are 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 in adr-byo-template.instructions.md.

Templates

  • templates/madr-v4.md — MADR v4.0.0 ADR template (verbatim, CC0). Used by Govern frontmatter assembly when state.outputTemplate == 'madr-v4'.

  • templates/y-statement.md — Six-slot Y-Statement formula for Decide assembly when state.outputTemplate == 'y-statement'.

  • templates/diagram-ascii.md — ASCII diagram block, selected when state.userPreferences.diagramFormat == "ascii".

  • templates/diagram-mermaid.md — Mermaid diagram block, selected when state.userPreferences.diagramFormat == "mermaid".

References

  • references/adr-identity.md — ADR Creator identity, three-phase state machine, six-step per-turn protocol, autonomy tiers, and the canonical state.json schema. 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 from templates/ against a Frame+Decide payload to produce an in-memory ADR draft. Path-traversal guarded: refuses any output path outside docs/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 of last_decision_id in .adr-config.yml. Mutates predecessor ADRs' superseded-by atomically with the new ADR's supersedes. Path-traversal guarded.

  • scripts/normalize_template.py — Converts a user-supplied ADR template into the canonical structure used by templates/madr-v4.md. Used only by the adopt-template lifecycle. 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. --public adds internal URL detection. --data independently 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 (tag 4.0.0, file template/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 the status: 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:

PhaseSection anchorRequired phaseSkillsLoaded entry
Frame#frameadr-author#frame
Decide#decideadr-author#decide
Govern#governadr-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

scripts/scan_sensitive_content.py

# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
"""Scan authored content for guarded disclosure risks.

Deterministic, regex-based scanner that flags high-confidence PII and, for
public repositories, internal-only URLs/hostnames before durable writes or
external handoff emission. Optional data mode adds structured column,
connection, credential, sample-row, and international identifier detection.

Findings carry a ``confidence`` label:

* ``high`` -- PII or a public-repository internal URL that must block
    durable/external writes until redacted. Any high-confidence finding sets a
    non-zero exit.
* ``warn`` -- advisory matches that surface for review but do not block on
    their own.

Input may be one or more file paths or, when no paths are given, stdin. Every
terminal path prints one JSON report on stdout. Exit 0 means the scan completed
with no high-confidence finding, exit 1 means it completed with at least one,
and exit 2 means the scan did not complete and ``status`` is ``error``. Callers
accept a durable write only on exit 0 with ``status`` of ``completed`` and the
expected ``modes`` attestation.

Usage::

    python -m scripts.scan_sensitive_content <path> [<path> ...]
    python -m scripts.scan_sensitive_content --data --denylist terms.txt <path>
    cat adr.md | python -m scripts.scan_sensitive_content
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Any, NamedTuple

try:
    from ._utils import safe_resolve
except ImportError:  # executed directly as ``python scan_sensitive_content.py``
    from _utils import safe_resolve

EXIT_SUCCESS = 0
EXIT_FAILURE = 1
EXIT_ERROR = 2

SCHEMA_VERSION = "scan-sensitive-content-v1"
STATUS_COMPLETED = "completed"
STATUS_ERROR = "error"

# Input bounds. Over-limit input fails closed before any rule executes.
MAX_INPUT_BYTES = 5 * 1024 * 1024
MAX_LINE_LENGTH = 10000
MAX_DENYLIST_BYTES = 64 * 1024
MAX_DENYLIST_TERMS = 1000
MIN_DENYLIST_TERM_LENGTH = 3

SKILL_ROOT = Path(__file__).resolve().parent.parent
REPO_ROOT = SKILL_ROOT.parents[3] if len(SKILL_ROOT.parents) >= 4 else SKILL_ROOT

STDIN_SOURCE = "<stdin>"


class ScanError(Exception):
    """A scan that could not complete, carrying a stable non-secret error code."""

    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code
        self.message = message


class Rule(NamedTuple):
    """A named detection rule with a compiled pattern and confidence label."""

    category: str
    confidence: str
    pattern: re.Pattern[str]


# High-confidence PII blocks durable/external writes. Names and roles are not
# scanned because deterministic regexes produce too many false positives there.
RULES: tuple[Rule, ...] = (
    Rule(
        "email_address",
        "high",
        re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
    ),
    Rule(
        "phone_number",
        "high",
        re.compile(r"\b(?:\+?1[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?)\d{3}[\s.-]?\d{4}\b"),
    ),
    Rule(
        "national_identifier",
        "high",
        re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
    ),
)


# Rules applied only when the target repository is public (``--public``).
# Internal-only URLs and hostnames are a leak concern only when the ADR or
# handoff content lands in a publicly visible repository; in a private repo
# they are expected operational references and flagging them is noise.
PUBLIC_ONLY_RULES: tuple[Rule, ...] = (
    Rule(
        "internal_url",
        "high",
        re.compile(
            r"https?://"
            r"(?:localhost|127\.0\.0\.1"
            r"|10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
            r"|192\.168\.\d{1,3}\.\d{1,3}"
            r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
            r"|[A-Za-z0-9.-]+\.(?:corp|internal|local))"
            r"(?:[:/][^\s)\"']*)?",
            re.IGNORECASE,
        ),
    ),
)


# Rules applied only in data mode. Column-name and sample-row detection use
# dedicated structural scanners below so ordinary prose does not activate
# those heuristics.
DATA_ONLY_RULES: tuple[Rule, ...] = (
    Rule(
        "connection_string",
        "high",
        re.compile(
            r"\b(?:Server|Data\s+Source|Initial\s+Catalog|User\s+Id|Password)\s*="
            r"[^\r\n;]*(?:;[^\r\n;=]+=[^\r\n;]*)+",
            re.IGNORECASE,
        ),
    ),
    Rule(
        "jdbc_odbc_uri",
        "high",
        re.compile(r"\b(?:jdbc|odbc):[^\s\"']+", re.IGNORECASE),
    ),
    Rule(
        "db_uri_with_credentials",
        "high",
        re.compile(
            r"\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://" r"[^\s/@:]+:[^\s/@]+@[^\s\"']+",
            re.IGNORECASE,
        ),
    ),
    Rule(
        "storage_key",
        "high",
        re.compile(r"\bAccountKey\s*=\s*[A-Za-z0-9+/]{20,}={0,2}", re.IGNORECASE),
    ),
    Rule(
        "bearer_token",
        "high",
        re.compile(r"\bAuthorization\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]{12,}", re.IGNORECASE),
    ),
    Rule(
        "private_key_block",
        "high",
        re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"),
    ),
    Rule(
        "cloud_access_key_id",
        "high",
        re.compile(r"\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA)[A-Z0-9]{16}\b"),
    ),
    Rule(
        "source_control_token",
        "high",
        re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
    ),
    Rule(
        "chat_webhook_token",
        "high",
        re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
    ),
    Rule(
        "web_token",
        "high",
        re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
    ),
    Rule(
        "generic_secret_assignment",
        "high",
        re.compile(
            r"\b(?:api[_-]?key|secret|password|passwd|token|client[_-]?secret)"
            r"\s*[:=]\s*[\"']?[A-Za-z0-9/+_=-]{16,}[\"']?",
            re.IGNORECASE,
        ),
    ),
    Rule(
        "uk_national_insurance",
        "warn",
        re.compile(r"\b[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b", re.IGNORECASE),
    ),
    Rule(
        "canadian_sin",
        "warn",
        re.compile(r"\b\d{3}[ -]\d{3}[ -]\d{3}\b"),
    ),
    Rule(
        "international_phone",
        "warn",
        re.compile(r"(?<!\w)\+[2-9]\d{7,14}\b"),
    ),
)

HIGH_COLUMN_NAMES = frozenset(
    {
        "ssn",
        "social_security",
        "national_id",
        "passport",
        "tax_id",
        "drivers_license",
        "credit_card",
        "card_number",
        "cvv",
        "account_number",
        "routing_number",
    }
)
WARN_COLUMN_NAMES = frozenset(
    {
        "dob",
        "date_of_birth",
        "birth_date",
        "email",
        "phone",
        "address",
        "postal",
        "zip",
        "patient_id",
        "member_id",
        "mrn",
        "full_name",
        "first_name",
        "last_name",
        "salary",
        "compensation",
    }
)

STRUCTURED_COLUMN_PATTERNS: tuple[re.Pattern[str], ...] = (
    re.compile(
        r"^\s*-?\s*[\"']?(?:name|field|column)[\"']?\s*:\s*" r"[\"']?(?P<column>[A-Za-z][A-Za-z0-9_-]*)",
        re.IGNORECASE,
    ),
    # Bare ``key: value`` declarations only count when the right-hand side is a
    # structural or type-like token; free prose such as "Address: see the
    # runbook" is not a column declaration.
    re.compile(
        r"^\s*[\"']?(?P<column>[A-Za-z][A-Za-z0-9_-]*)[\"']?\s*:(?P<value>.*)$",
        re.IGNORECASE,
    ),
    re.compile(
        r"^\s*[\"`\[]?(?P<column>[A-Za-z][A-Za-z0-9_-]*)[\"`\]]?\s+"
        r"(?:bigint|boolean|date|datetime|decimal|float|int|integer|numeric|"
        r"text|timestamp|varchar)\b",
        re.IGNORECASE,
    ),
)
SCALAR_TYPE_NAMES = frozenset(
    {
        "bigint",
        "bool",
        "boolean",
        "bytes",
        "char",
        "date",
        "datetime",
        "decimal",
        "double",
        "float",
        "int",
        "int32",
        "int64",
        "integer",
        "json",
        "long",
        "null",
        "number",
        "numeric",
        "object",
        "str",
        "string",
        "text",
        "time",
        "timestamp",
        "uuid",
        "varchar",
    }
)
INLINE_COLUMNS_PATTERN = re.compile(
    r"[\"']?(?:columns?|fields?)[\"']?\s*:\s*\[(?P<columns>[^\]]*)\]",
    re.IGNORECASE,
)
IDENTIFIER_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
SAMPLE_CONTEXT_PATTERN = re.compile(
    r"(?:^\s*#{1,6}\s*|^\s*[\"']?)(?:sample|example|preview)(?:\s+rows?|\s+data)?" r"(?:[\"']?\s*:|\s*$)",
    re.IGNORECASE,
)
SAMPLE_CONTEXT_MAX_LINES = 50
MARKDOWN_HEADING_PATTERN = re.compile(r"^\s*#{1,6}\s")
CODE_FENCE_PATTERN = re.compile(r"^\s*(?:```|~~~)")
MARKDOWN_TABLE_ROW_PATTERN = re.compile(r"^\s*\|(?:[^|]+\|){2,}\s*$")
# Alignment/separator rows carry no data and must not be reported as samples.
MARKDOWN_SEPARATOR_CELL_PATTERN = re.compile(r"^[\s:-]+$")
JSON_ARRAY_PATTERN = re.compile(r"^\s*\[(?:[^\[\]]|\[[^\]]*\])*\]\s*,?\s*$")
SAS_URL_PATTERN = re.compile(r"https?://[^\s\"']{1,512}\?[^\s\"']{1,2048}", re.IGNORECASE)
# Documentation placeholders must not block writes.
PLACEHOLDER_VALUE_PATTERN = re.compile(
    r"^(?:[x*]{3,}"
    r"|<[^<>]{0,200}>"
    r"|[A-Za-z0-9_-]{0,80}(?:redacted|changeme|placeholder|todo|example|your[_-])"
    r"[A-Za-z0-9_-]{0,80})$",
    re.IGNORECASE,
)

# Categories whose matched text is itself the secret (or the customer-specific
# term the denylist exists to protect). No fragment of the match is reported.
NO_PREVIEW_CATEGORIES = frozenset(
    {
        "connection_string",
        "jdbc_odbc_uri",
        "db_uri_with_credentials",
        "storage_key",
        "bearer_token",
        "sas_token",
        "private_key_block",
        "cloud_access_key_id",
        "source_control_token",
        "chat_webhook_token",
        "web_token",
        "generic_secret_assignment",
        "sample_row",
        "denylist_term",
    }
)
REDACTED_PREVIEW = "[redacted]"


def _iter_lines(text: str) -> Iterator[tuple[int, str]]:
    """Yield 1-based numbered lines truncated to ``MAX_LINE_LENGTH``.

    Truncation bounds per-line regex work so one pathological line cannot
    dominate runtime; content past the cap is not scanned.
    """
    for line_number, line in enumerate(text.splitlines(), start=1):
        yield line_number, line[:MAX_LINE_LENGTH]


def _redact(match: str) -> str:
    """Return a masked preview of matched content for safe reporting."""
    stripped = match.strip()
    if len(stripped) <= 8:
        return stripped[0] + "***" if stripped else "***"
    return f"{stripped[:4]}***{stripped[-2:]}"


def _finding(
    source: str,
    line_number: int,
    column: int,
    category: str,
    confidence: str,
    match: str,
) -> dict[str, Any]:
    """Build one finding, suppressing any preview for secret-bearing categories."""
    finding: dict[str, Any] = {
        "source": source,
        "line": line_number,
        "column": column,
        "category": category,
        "confidence": confidence,
    }
    if category in NO_PREVIEW_CATEGORIES:
        finding["match"] = REDACTED_PREVIEW
        finding["length"] = len(match.strip())
    else:
        finding["match"] = _redact(match)
    return finding


def _assignment_value(match: str) -> str:
    """Return the right-hand side of the first ``:`` or ``=`` in ``match``."""
    parts = re.split(r"[:=]", match, maxsplit=1)
    if len(parts) < 2:
        return ""
    return parts[1].strip().strip("\"'")


def _is_structural_value(value: str) -> bool:
    """Return True when a ``key: value`` right-hand side is structural, not prose."""
    stripped = value.strip().rstrip(",").strip()
    if not stripped or stripped in {"{", "[", "|", ">", "{}", "[]", "~"}:
        return True
    if len(stripped) >= 2 and stripped[0] in "\"'" and stripped[-1] in "\"'":
        return True
    if re.fullmatch(r"-?\d+(?:\.\d+)?", stripped):
        return True
    return stripped.split("(", 1)[0].strip().lower() in SCALAR_TYPE_NAMES


def _normalize_identifier(value: str) -> str:
    """Normalize snake, kebab, and camel-case identifiers for comparison."""
    value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", value)
    return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()


def _scan_structured_columns(text: str, source: str) -> list[dict[str, Any]]:
    """Detect sensitive column names only in structured declarations."""
    findings: list[dict[str, Any]] = []
    for line_number, line in _iter_lines(text):
        candidates: list[tuple[str, int]] = []
        for pattern in STRUCTURED_COLUMN_PATTERNS:
            match = pattern.search(line)
            if not match:
                continue
            value = match.groupdict().get("value")
            if value is not None and not _is_structural_value(value):
                continue
            candidates.append((match.group("column"), match.start("column")))
            break
        inline = INLINE_COLUMNS_PATTERN.search(line)
        if inline:
            candidates.extend(
                (match.group(0), match.start()) for match in IDENTIFIER_PATTERN.finditer(inline.group("columns"))
            )
        for candidate, column in candidates:
            normalized = _normalize_identifier(candidate)
            confidence = (
                "high" if normalized in HIGH_COLUMN_NAMES else "warn" if normalized in WARN_COLUMN_NAMES else None
            )
            if confidence:
                findings.append(
                    _finding(
                        source,
                        line_number,
                        column + 1,
                        "sensitive_column_name",
                        confidence,
                        candidate,
                    )
                )
    return findings


def _is_sample_row(line: str) -> bool:
    """Return True for a data-bearing table or array row.

    Markdown alignment rows (cells of only ``-``, ``:``, and whitespace) carry
    no data and are excluded.
    """
    if JSON_ARRAY_PATTERN.match(line):
        return True
    if not MARKDOWN_TABLE_ROW_PATTERN.match(line):
        return False
    cells = [cell for cell in line.strip().strip("|").split("|")]
    return not all(MARKDOWN_SEPARATOR_CELL_PATTERN.match(cell) for cell in cells)


def _scan_sample_rows(text: str, source: str) -> list[dict[str, Any]]:
    """Warn on table or array rows in an explicit, bounded sample context.

    The context ends at the next heading, a fenced-code delimiter, a blank line
    after at least one matched row, or ``SAMPLE_CONTEXT_MAX_LINES`` lines from
    the context start, so unrelated structures further down are not attributed
    to the sample.
    """
    findings: list[dict[str, Any]] = []
    sample_context = False
    context_start = 0
    matched_rows = 0
    for line_number, line in _iter_lines(text):
        if SAMPLE_CONTEXT_PATTERN.search(line):
            sample_context = True
            context_start = line_number
            matched_rows = 0
            continue
        if not sample_context:
            continue
        if (
            MARKDOWN_HEADING_PATTERN.match(line)
            or CODE_FENCE_PATTERN.match(line)
            or (not line.strip() and matched_rows > 0)
            or line_number - context_start > SAMPLE_CONTEXT_MAX_LINES
        ):
            sample_context = False
            continue
        if _is_sample_row(line):
            matched_rows += 1
            findings.append(_finding(source, line_number, 1, "sample_row", "warn", line))
    return findings


def _scan_sas_tokens(text: str, source: str) -> list[dict[str, Any]]:
    """Detect SAS URLs only when signature, expiry, and permission fields coexist."""
    findings: list[dict[str, Any]] = []
    for line_number, line in _iter_lines(text):
        for match in SAS_URL_PATTERN.finditer(line):
            query = match.group(0).split("?", 1)[1].lower()
            fields = {part.split("=", 1)[0] for part in query.split("&") if "=" in part}
            if {"sig", "se", "sp"}.issubset(fields):
                findings.append(
                    _finding(
                        source,
                        line_number,
                        match.start() + 1,
                        "sas_token",
                        "high",
                        match.group(0),
                    )
                )
    return findings


def _build_denylist_rules(terms: list[str]) -> tuple[Rule, ...]:
    """Build case-insensitive literal high-confidence rules."""
    return tuple(Rule("denylist_term", "high", re.compile(re.escape(term), re.IGNORECASE)) for term in terms)


def _finding_sort_key(finding: dict[str, Any]) -> tuple[str, int, int, str]:
    """Order findings deterministically across multi-path runs."""
    return (finding["source"], finding["line"], finding["column"], finding["category"])


def _load_denylist(path: Path) -> tuple[Rule, ...]:
    """Read unique nonblank UTF-8 denylist terms without exposing them.

    Fails closed: an unreadable, oversized, or effectively empty denylist is a
    configuration error rather than a scan with no denylist rules.
    """
    try:
        if not path.is_file():
            raise OSError("path is not a readable file")
        size = path.stat().st_size
    except OSError as exc:
        raise ScanError("denylist_unreadable", "denylist must be a readable UTF-8 text file") from exc
    if size > MAX_DENYLIST_BYTES:
        raise ScanError("denylist_too_large", f"denylist exceeds {MAX_DENYLIST_BYTES} bytes")
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeError) as exc:
        raise ScanError("denylist_unreadable", "denylist must be a readable UTF-8 text file") from exc
    terms: list[str] = []
    seen: set[str] = set()
    for line in lines:
        term = line.strip()
        if not term:
            continue
        if len(term) < MIN_DENYLIST_TERM_LENGTH:
            raise ScanError(
                "denylist_term_too_short",
                f"denylist terms must be at least {MIN_DENYLIST_TERM_LENGTH} characters",
            )
        key = term.casefold()
        if key in seen:
            continue
        terms.append(term)
        seen.add(key)
        if len(terms) > MAX_DENYLIST_TERMS:
            raise ScanError("denylist_too_large", f"denylist exceeds {MAX_DENYLIST_TERMS} terms")
    if not terms:
        raise ScanError("denylist_empty", "denylist contains no usable terms")
    return _build_denylist_rules(terms)


def scan_text(
    text: str,
    source: str,
    *,
    public: bool = False,
    data: bool = False,
    denylist_rules: tuple[Rule, ...] = (),
) -> list[dict[str, Any]]:
    """Return a list of finding dicts for ``text`` attributed to ``source``.

    When ``public`` is true, internal-URL rules are included; in a private
    repository those references are expected and are not flagged.
    """
    active_rules = RULES
    if public:
        active_rules = (*active_rules, *PUBLIC_ONLY_RULES)
    if data:
        active_rules = (*active_rules, *DATA_ONLY_RULES)
    active_rules = (*active_rules, *denylist_rules)
    findings: list[dict[str, Any]] = []
    for line_number, line in _iter_lines(text):
        for rule in active_rules:
            for match in rule.pattern.finditer(line):
                if rule.category == "generic_secret_assignment" and PLACEHOLDER_VALUE_PATTERN.match(
                    _assignment_value(match.group(0))
                ):
                    continue
                findings.append(
                    _finding(
                        source,
                        line_number,
                        match.start() + 1,
                        rule.category,
                        rule.confidence,
                        match.group(0),
                    )
                )
    if data:
        findings.extend(_scan_structured_columns(text, source))
        findings.extend(_scan_sample_rows(text, source))
        findings.extend(_scan_sas_tokens(text, source))
    findings.sort(key=_finding_sort_key)
    return findings


def create_parser() -> argparse.ArgumentParser:
    """Create the argument parser."""
    parser = argparse.ArgumentParser(
        prog="scan_sensitive_content",
        description=("Scan ADR/handoff content for high-confidence PII and public-repository internal URLs."),
    )
    parser.add_argument(
        "paths",
        nargs="*",
        type=Path,
        help="File paths to scan; reads stdin when no paths are given.",
    )
    parser.add_argument(
        "--allow-root",
        type=Path,
        action="append",
        default=[],
        help="Additional directory under which scanned paths may live.",
    )
    parser.add_argument(
        "--public",
        action="store_true",
        help=(
            "Treat the target repository as public; enables internal-URL "
            "detection, which is suppressed for private repositories."
        ),
    )
    parser.add_argument(
        "--data",
        action="store_true",
        help=(
            "Enable structured column, connection, credential, sample-row, "
            "and international identifier detection for data artifacts."
        ),
    )
    parser.add_argument(
        "--denylist",
        type=Path,
        help=(
            "UTF-8 text file containing one literal customer-specific term "
            "per line; enables high-confidence matching independently."
        ),
    )
    return parser


def _summarize(findings: list[dict[str, Any]]) -> dict[str, int]:
    """Return high/warn/total counts for ``findings``."""
    return {
        "high": sum(1 for finding in findings if finding["confidence"] == "high"),
        "warn": sum(1 for finding in findings if finding["confidence"] == "warn"),
        "total": len(findings),
    }


def _build_report(
    *,
    status: str,
    modes: dict[str, bool],
    denylist_rule_count: int,
    findings: list[dict[str, Any]],
    error: ScanError | None = None,
) -> dict[str, Any]:
    """Build the versioned report emitted on every terminal path."""
    report: dict[str, Any] = {
        "schema_version": SCHEMA_VERSION,
        "status": status,
    }
    if error is not None:
        report["error"] = {"code": error.code, "message": error.message}
    report["modes"] = modes
    report["denylist_rule_count"] = denylist_rule_count
    report["findings"] = findings
    report["summary"] = _summarize(findings)
    return report


def _allow_roots(args: argparse.Namespace) -> list[Path]:
    """Build the allow-root list shared by scanned paths and the denylist."""
    roots: list[Path] = [SKILL_ROOT, REPO_ROOT]
    for raw in args.paths:
        try:
            roots.append(raw.expanduser().resolve().parent)
        except OSError:
            # Unresolvable path (e.g. broken symlink); safe_resolve rejects it
            # explicitly rather than widening the allowlist here.
            continue
    for raw in args.allow_root:
        try:
            roots.append(raw.expanduser().resolve())
        except OSError:
            continue
    return roots


def _scan_paths(
    args: argparse.Namespace,
    allow_roots: list[Path],
    denylist_rules: tuple[Rule, ...],
) -> list[dict[str, Any]]:
    """Scan every requested path, failing closed on any path, size, or read failure."""
    findings: list[dict[str, Any]] = []
    total_bytes = 0
    for raw in args.paths:
        try:
            resolved = safe_resolve(raw, allow_roots)
        except ValueError as exc:
            raise ScanError("path_error", f"path error: {exc}") from exc
        try:
            total_bytes += resolved.stat().st_size
        except OSError as exc:
            raise ScanError("read_error", f"failed to read '{raw}'") from exc
        if total_bytes > MAX_INPUT_BYTES:
            raise ScanError("input_too_large", f"input exceeds {MAX_INPUT_BYTES} bytes")
        try:
            text = resolved.read_text(encoding="utf-8")
        except UnicodeDecodeError as exc:
            raise ScanError("decode_error", f"failed to decode '{raw}' as UTF-8") from exc
        except OSError as exc:
            raise ScanError("read_error", f"failed to read '{raw}'") from exc
        findings.extend(
            scan_text(
                text,
                str(raw),
                public=args.public,
                data=args.data,
                denylist_rules=denylist_rules,
            )
        )
    return findings


def _scan_stdin(
    args: argparse.Namespace,
    denylist_rules: tuple[Rule, ...],
) -> list[dict[str, Any]]:
    """Scan stdin, failing closed on decode failure or over-limit input."""
    try:
        text = sys.stdin.read()
    except UnicodeDecodeError as exc:
        raise ScanError("decode_error", "failed to decode stdin as UTF-8") from exc
    except OSError as exc:
        raise ScanError("read_error", "failed to read stdin") from exc
    if len(text.encode("utf-8", errors="ignore")) > MAX_INPUT_BYTES:
        raise ScanError("input_too_large", f"input exceeds {MAX_INPUT_BYTES} bytes")
    return scan_text(
        text,
        STDIN_SOURCE,
        public=args.public,
        data=args.data,
        denylist_rules=denylist_rules,
    )


def main(argv: list[str] | None = None) -> int:
    """Main entry point."""
    args = create_parser().parse_args(argv)
    modes = {
        "public": bool(args.public),
        "data": bool(args.data),
        "denylist": args.denylist is not None,
    }
    denylist_rules: tuple[Rule, ...] = ()

    try:
        allow_roots = _allow_roots(args)
        if args.denylist is not None:
            try:
                denylist_path = safe_resolve(args.denylist, allow_roots)
            except ValueError as exc:
                raise ScanError("path_error", f"denylist path error: {exc}") from exc
            denylist_rules = _load_denylist(denylist_path)
        findings = _scan_paths(args, allow_roots, denylist_rules) if args.paths else _scan_stdin(args, denylist_rules)
    except ScanError as exc:
        print(f"scan_sensitive_content: {exc.message}", file=sys.stderr)
        print(
            json.dumps(
                _build_report(
                    status=STATUS_ERROR,
                    modes=modes,
                    denylist_rule_count=len(denylist_rules),
                    findings=[],
                    error=exc,
                ),
                indent=2,
            )
        )
        return EXIT_ERROR

    findings.sort(key=_finding_sort_key)
    report = _build_report(
        status=STATUS_COMPLETED,
        modes=modes,
        denylist_rule_count=len(denylist_rules),
        findings=findings,
    )
    print(json.dumps(report, indent=2))

    return EXIT_FAILURE if report["summary"]["high"] else EXIT_SUCCESS


if __name__ == "__main__":
    sys.exit(main())

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.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download adr-author.zip