From 5004c9862d6a8937cc5d01c854e4e2294fb0a085 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 14:07:51 +0800 Subject: [PATCH 01/13] feat(skills): add `azd ai skill` command group (preview) Introduces a new standalone `azure.ai.skills` extension that exposes `azd ai skill create | update | show | list | download | delete` for managing Foundry Skills from any directory. Implements the design in PR #8204 / issue #8142: - New extension under `cli/azd/extensions/azure.ai.skills` (namespace `ai.skill`, id `azure.ai.skills`, version `0.0.1-preview`). - Typed Foundry Skills data-plane client in `internal/pkg/skill_api` with SKILL.md YAML front matter parser and two-phase safe gzip-tar extractor (zip-slip guard, no symlinks / hard links, 10,000-entry / 512 MB caps, staging + atomic copy, `--force` for collisions). - Three mutually exclusive `create` modes: inline (`--description` + `--instructions`), `--file SKILL.md` (parsed locally), and `--file *.tar.gz` / `*.tgz` (streamed as `application/gzip` to `POST /skills:import`). `--force` does delete-then-create. - `update` accepts inline flags or `--file *.md` only; gzip is rejected with a structured suggestion to use `create --force`. - `download` extracts by default into `./.agents/skills//` and supports `--raw` to write the unmodified archive. - `delete` confirms by default; `--force` skips, and `--no-prompt` without `--force` errors. Interactive `n` returns exit 0. - Endpoint resolution shares the 5-level cascade with `azure.ai.agents` via a read-only fallback to `extensions.ai-agents.project.context.endpoint` when the new `extensions.ai-skills.project.context.endpoint` key is unset. - Bearer scope `https://ai.azure.com/.default`, `Foundry-Features: Skills=V1Preview` header, API version `2025-11-15-preview`. Debug log file `azd-ai-skills-.log`. `IncludeBody` opt-out on the HTTP pipeline until a sanitizer for `description` / `instructions` lands. - Skill name regex aligned with `agent_yaml.ValidateAgentName`: `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\$`. Closes #8142. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.skills/.golangci.yaml | 17 + cli/azd/extensions/azure.ai.skills/AGENTS.md | 73 ++++ .../extensions/azure.ai.skills/CHANGELOG.md | 17 + cli/azd/extensions/azure.ai.skills/README.md | 75 ++++ cli/azd/extensions/azure.ai.skills/build.ps1 | 78 ++++ cli/azd/extensions/azure.ai.skills/build.sh | 66 +++ .../extensions/azure.ai.skills/ci-build.ps1 | 150 +++++++ .../extensions/azure.ai.skills/ci-test.ps1 | 27 ++ .../extensions/azure.ai.skills/cspell.yaml | 13 + .../extensions/azure.ai.skills/extension.yaml | 23 + cli/azd/extensions/azure.ai.skills/go.mod | 104 +++++ cli/azd/extensions/azure.ai.skills/go.sum | 314 ++++++++++++++ .../azure.ai.skills/internal/cmd/debug.go | 81 ++++ .../azure.ai.skills/internal/cmd/endpoint.go | 103 +++++ .../internal/cmd/endpoint_test.go | 35 ++ .../azure.ai.skills/internal/cmd/output.go | 87 ++++ .../internal/cmd/output_test.go | 30 ++ .../azure.ai.skills/internal/cmd/root.go | 81 ++++ .../internal/cmd/skill_context.go | 62 +++ .../internal/cmd/skill_create.go | 397 +++++++++++++++++ .../internal/cmd/skill_delete.go | 143 +++++++ .../internal/cmd/skill_download.go | 231 ++++++++++ .../internal/cmd/skill_list.go | 81 ++++ .../internal/cmd/skill_show.go | 73 ++++ .../internal/cmd/skill_update.go | 193 +++++++++ .../internal/cmd/skill_validate.go | 44 ++ .../internal/cmd/skill_validate_test.go | 160 +++++++ .../azure.ai.skills/internal/cmd/version.go | 24 ++ .../internal/exterrors/codes.go | 52 +++ .../internal/exterrors/errors.go | 59 +++ .../internal/pkg/skill_api/archive.go | 301 +++++++++++++ .../internal/pkg/skill_api/archive_test.go | 205 +++++++++ .../internal/pkg/skill_api/client.go | 398 ++++++++++++++++++ .../internal/pkg/skill_api/client_test.go | 200 +++++++++ .../internal/pkg/skill_api/models.go | 115 +++++ .../internal/pkg/skill_api/skill_md.go | 212 ++++++++++ .../internal/pkg/skill_api/skill_md_test.go | 89 ++++ .../internal/version/version.go | 11 + cli/azd/extensions/azure.ai.skills/main.go | 14 + .../extensions/azure.ai.skills/version.txt | 1 + 40 files changed, 4439 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.skills/.golangci.yaml create mode 100644 cli/azd/extensions/azure.ai.skills/AGENTS.md create mode 100644 cli/azd/extensions/azure.ai.skills/CHANGELOG.md create mode 100644 cli/azd/extensions/azure.ai.skills/README.md create mode 100644 cli/azd/extensions/azure.ai.skills/build.ps1 create mode 100644 cli/azd/extensions/azure.ai.skills/build.sh create mode 100644 cli/azd/extensions/azure.ai.skills/ci-build.ps1 create mode 100644 cli/azd/extensions/azure.ai.skills/ci-test.ps1 create mode 100644 cli/azd/extensions/azure.ai.skills/cspell.yaml create mode 100644 cli/azd/extensions/azure.ai.skills/extension.yaml create mode 100644 cli/azd/extensions/azure.ai.skills/go.mod create mode 100644 cli/azd/extensions/azure.ai.skills/go.sum create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/output.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/root.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/version.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/version/version.go create mode 100644 cli/azd/extensions/azure.ai.skills/main.go create mode 100644 cli/azd/extensions/azure.ai.skills/version.txt diff --git a/cli/azd/extensions/azure.ai.skills/.golangci.yaml b/cli/azd/extensions/azure.ai.skills/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.skills/AGENTS.md b/cli/azd/extensions/azure.ai.skills/AGENTS.md new file mode 100644 index 00000000000..8ac93592d31 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/AGENTS.md @@ -0,0 +1,73 @@ +# Azure AI Skills Extension - Agent Instructions + +Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root azd instructions with the conventions that are specific to this extension. + +## Overview + +`azure.ai.skills` is a first-party azd extension under `cli/azd/extensions/azure.ai.skills/`. It runs as a separate Go binary and talks to the azd host over gRPC. It exposes the `azd ai skill ` command group for managing Foundry Skills. + +Useful places to start: + +- `internal/cmd/`: Cobra commands and top-level orchestration +- `internal/pkg/skill_api/`: typed Foundry Skills REST client, models, SKILL.md parser, and safe tar.gz extractor +- `internal/exterrors/`: structured error factories and extension-specific codes + +## Relationship to `azure.ai.agents` + +This extension is intentionally separate from `azure.ai.agents`. It shares no code symbols but cooperates with it via the global-config endpoint key: + +- This extension writes to `extensions.ai-skills.project.context.endpoint` (none yet — read-only today). +- This extension reads `extensions.ai-skills.project.context.endpoint` first, then falls back to `extensions.ai-agents.project.context.endpoint` so users who already configured the endpoint via the agents extension are not forced to re-run `set`. + +`AgentCardSkill` (in `azure.ai.agents`) is unrelated to the `Skill` resource managed here and lives in a different Go module. + +## Build and test + +From `cli/azd/extensions/azure.ai.skills`: + +```bash +# Build using developer extension (for local development) +azd x build + +# Or build using Go directly +go build +``` + +If extension work depends on a new azd core change, plan for two PRs: + +1. Land the core change in `cli/azd` first. +2. Land the extension change after that, updating this module to the newer azd dependency with `go get github.com/azure/azure-dev/cli/azd && go mod tidy`. + +For local development, draft work, or validating both sides together before the core PR is merged, you may temporarily add: + +```go +replace github.com/azure/azure-dev/cli/azd => ../../ +``` + +That `replace` points this extension at your local `cli/azd` checkout instead of the version in `go.mod`. Do not merge the extension with that `replace` still present. + +## Error handling + +This extension uses `internal/exterrors` so the azd host can show a useful message, attach an optional suggestion, and emit stable telemetry. See `cli/azd/extensions/azure.ai.agents/AGENTS.md` "Error handling" section for the full conventions — they apply here unchanged. + +Skill-specific error codes live in `internal/exterrors/codes.go`: + +- `CodeInvalidSkillName` — name fails the alphanumeric-with-hyphens regex +- `CodeInvalidSkillFile` — SKILL.md front matter unparsable, or `--file` extension unsupported +- `CodeSkillArchiveUnsafe` — `download` rejected a tar entry (zip-slip, symlink, oversized, etc.) +- `CodeSkillOutputCollision` — `download` would overwrite an existing file without `--force` + +## Debug logging + +Each `--debug` run writes to `azd-ai-skills-.log` in the current working directory. The `skill_api` client deliberately opts out of `IncludeBody` request/response logging until a sanitizer is in place that redacts user-authored `description` and `instructions` fields. Do not enable body logging without that sanitizer. + +## File handling + +- `--file` is **not** a manifest. It is read at invocation time only; the CLI does not track or re-read it after the command returns. +- `create`: accepts `.md`, `.tar.gz`, or `.tgz`. Mode is inferred from extension; conflicting modes (inline + `--file`) are rejected. +- `update`: accepts `.md` only. `.tar.gz`/`.tgz` is rejected with a structured suggestion to use `create --force`. +- `download`: writes either an extracted directory (default) or the unmodified gzip archive (`--raw`). + +## Release preparation + +Follows the same two-PR convention as `azure.ai.agents`: a version-bump PR that touches only `version.txt`, `extension.yaml`, and `CHANGELOG.md`, followed by a registry-update PR generated by `azd x publish` against the released artifacts. diff --git a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md new file mode 100644 index 00000000000..eb2c71cf03e --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md @@ -0,0 +1,17 @@ +# Release History + +## 0.0.1-preview (Unreleased) + +- Initial preview release of the `azure.ai.skills` extension. +- Adds the `azd ai skill` command group with full CRUD over Foundry Skills: + - `azd ai skill create ` — inline (`--description` + `--instructions`), + SKILL.md file (`--file ./SKILL.md`), or gzip package (`--file ./skill.tar.gz`). + - `azd ai skill update ` — inline or `--file *.md`. + - `azd ai skill show ` — metadata only. + - `azd ai skill list` — paginated, supports `--top` and `--orderby`. + - `azd ai skill download ` — extracts to `./.agents/skills//` by + default, `--raw` keeps the gzip archive. + - `azd ai skill delete ` — confirmation by default, `--force` to skip. +- Shares the Foundry project-endpoint resolution cascade with `azure.ai.agents`, + reading `extensions.ai-skills.project.context.endpoint` first and falling back to + `extensions.ai-agents.project.context.endpoint`. diff --git a/cli/azd/extensions/azure.ai.skills/README.md b/cli/azd/extensions/azure.ai.skills/README.md new file mode 100644 index 00000000000..b4d5354e8f4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/README.md @@ -0,0 +1,75 @@ +# Azure Developer CLI (azd) Skills Extension + +Manage [Microsoft Foundry](https://learn.microsoft.com/azure/ai-services/) **skills** +(reusable behavioral guidelines an agent can attach at runtime) directly from your +terminal. + +## Commands + +```bash +azd ai skill create [--description "..." --instructions "..."] +azd ai skill create --file ./SKILL.md +azd ai skill create --file ./skill.tar.gz + +azd ai skill update [--description "..."] [--instructions "..."] [--file ./SKILL.md] +azd ai skill show +azd ai skill list [--top N] [--orderby ] +azd ai skill download [--output-dir ] [--raw] [--force] +azd ai skill delete [--force] +``` + +All commands accept the standard cross-cutting flags: `-p` / `--project-endpoint`, +`--output table|json`, `--no-prompt`, and `--debug`. + +## Project endpoint resolution + +The Foundry project endpoint is resolved in this order: + +1. `-p` / `--project-endpoint` flag on the command. +2. Active azd env value `AZURE_AI_PROJECT_ENDPOINT`. +3. Global config `extensions.ai-skills.project.context.endpoint` + (falls back to `extensions.ai-agents.project.context.endpoint` so users who + configured the endpoint via the agents extension are not forced to re-run `set`). +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT`. +5. Structured error with an actionable suggestion. + +## Local Development + +### Prerequisites + +1. **Install developer kit extension** (if not already installed): + + ```bash + azd ext install microsoft.azd.extensions + ``` + +### Building and installing locally + +1. **Navigate to the extension directory**: + + ```bash + cd cli/azd/extensions/azure.ai.skills + ``` + +2. **Initial setup** (first time only): + + ```bash + azd x build + azd x pack + azd x publish + ``` + +3. **Install the extension**: + + ```bash + azd ext install azure.ai.skills + ``` + +4. **For subsequent development** (after initial setup): + + ```bash + azd x watch + ``` + + This automatically watches for file changes, rebuilds, and installs updates + locally. diff --git a/cli/azd/extensions/azure.ai.skills/build.ps1 b/cli/azd/extensions/azure.ai.skills/build.ps1 new file mode 100644 index 00000000000..6ddfeb6a65e --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/build.ps1 @@ -0,0 +1,78 @@ +# Ensure script fails on any error +$ErrorActionPreference = 'Stop' + +# Get the directory of the script +$EXTENSION_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Change to the script directory +Set-Location -Path $EXTENSION_DIR + +# Create a safe version of EXTENSION_ID replacing dots with dashes +$EXTENSION_ID_SAFE = $env:EXTENSION_ID -replace '\.', '-' + +# Define output directory +$OUTPUT_DIR = if ($env:OUTPUT_DIR) { $env:OUTPUT_DIR } else { Join-Path $EXTENSION_DIR "bin" } + +# Create output directory if it doesn't exist +if (-not (Test-Path -Path $OUTPUT_DIR)) { + New-Item -ItemType Directory -Path $OUTPUT_DIR | Out-Null +} + +# Get Git commit hash and build date +$COMMIT = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Failed to get git commit hash" + exit 1 +} +$BUILD_DATE = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") + +# List of OS and architecture combinations +if ($env:EXTENSION_PLATFORM) { + $PLATFORMS = @($env:EXTENSION_PLATFORM) +} +else { + $PLATFORMS = @( + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64" + ) +} + +$VERSION_PATH = "azureaiskills/internal/version" + +# Loop through platforms and build +foreach ($PLATFORM in $PLATFORMS) { + $OS, $ARCH = $PLATFORM -split '/' + + $OUTPUT_NAME = Join-Path $OUTPUT_DIR "$EXTENSION_ID_SAFE-$OS-$ARCH" + + if ($OS -eq "windows") { + $OUTPUT_NAME += ".exe" + } + + Write-Host "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + if (Test-Path -Path $OUTPUT_NAME) { + Remove-Item -Path $OUTPUT_NAME -Force + } + + # Set environment variables for Go build + $env:GOOS = $OS + $env:GOARCH = $ARCH + + go build ` + -ldflags="-X '$VERSION_PATH.Version=$env:EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_PATH.BuildDate=$BUILD_DATE'" ` + -o $OUTPUT_NAME + + if ($LASTEXITCODE -ne 0) { + Write-Host "An error occurred while building for $OS/$ARCH" + exit 1 + } +} + +Write-Host "Build completed successfully!" +Write-Host "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/azure.ai.skills/build.sh b/cli/azd/extensions/azure.ai.skills/build.sh new file mode 100644 index 00000000000..0f508b130a2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/build.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Get the directory of the script +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change to the script directory +cd "$EXTENSION_DIR" || exit + +# Create a safe version of EXTENSION_ID replacing dots with dashes +EXTENSION_ID_SAFE="${EXTENSION_ID//./-}" + +# Define output directory +OUTPUT_DIR="${OUTPUT_DIR:-$EXTENSION_DIR/bin}" + +# Create output and target directories if they don't exist +mkdir -p "$OUTPUT_DIR" + +# Get Git commit hash and build date +COMMIT=$(git rev-parse HEAD) +BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# List of OS and architecture combinations +if [ -n "$EXTENSION_PLATFORM" ]; then + PLATFORMS=("$EXTENSION_PLATFORM") +else + PLATFORMS=( + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + ) +fi + +VERSION_PATH="azureaiskills/internal/version" + +# Loop through platforms and build +for PLATFORM in "${PLATFORMS[@]}"; do + OS=$(echo "$PLATFORM" | cut -d'/' -f1) + ARCH=$(echo "$PLATFORM" | cut -d'/' -f2) + + OUTPUT_NAME="$OUTPUT_DIR/$EXTENSION_ID_SAFE-$OS-$ARCH" + + if [ "$OS" = "windows" ]; then + OUTPUT_NAME+='.exe' + fi + + echo "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + [ -f "$OUTPUT_NAME" ] && rm -f "$OUTPUT_NAME" + + # Set environment variables for Go build + GOOS=$OS GOARCH=$ARCH go build \ + -ldflags="-X '$VERSION_PATH.Version=$EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_PATH.BuildDate=$BUILD_DATE'" \ + -o "$OUTPUT_NAME" + + if [ $? -ne 0 ]; then + echo "An error occurred while building for $OS/$ARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +echo "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/azure.ai.skills/ci-build.ps1 b/cli/azd/extensions/azure.ai.skills/ci-build.ps1 new file mode 100644 index 00000000000..56992c10be5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/ci-build.ps1 @@ -0,0 +1,150 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/../version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) +$PSNativeCommandArgumentPassing = 'Legacy' + +# Remove any previously built binaries +go clean + +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +# Run `go help build` to obtain detailed information about `go build` flags. +$buildFlags = @( + # remove all file system paths from the resulting executable. + # Instead of absolute file system paths, the recorded file names + # will begin either a module path@version (when using modules), + # or a plain import path (when using the standard library, or GOPATH). + "-trimpath", + + # Use buildmode=pie (Position Independent Executable) for enhanced security across platforms + # against memory corruption exploits across all major platforms. + # + # On Windows, the -buildmode=pie flag enables Address Space Layout + # Randomization (ASLR) and automatically sets DYNAMICBASE and HIGH-ENTROPY-VA flags in the PE header. + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +# Build constraint tags +# cfi: Enable Control Flow Integrity (CFI), +# cfg: Enable Control Flow Guard (CFG), +# osusergo: Optimize for OS user accounts +$tagsFlag = "-tags=cfi,cfg,osusergo" + +# ld linker flags +# -s: Omit symbol table and debug information +# -w: Omit DWARF symbol table +# -X: Set variable at link time. Used to set the version in source. + +$ldFlag = "-ldflags=-s -w -X 'azureaiskills/internal/version.Version=$Version' -X 'azureaiskills/internal/version.Commit=$SourceVersion' -X 'azureaiskills/internal/version.BuildDate=$(Get-Date -Format o)' " + +if ($IsWindows) { + $msg = "Building for Windows" + Write-Host $msg +} +elseif ($IsLinux) { + Write-Host "Building for linux" + + # Disable cgo in the x64 Linux build. This will also statically + # link the resulting binary which increases backwards + # compatibility with older versions of Linux. + if ($env:GOARCH -ne "arm64") { + $env:CGO_ENABLED = "0" + } +} +elseif ($IsMacOS) { + Write-Host "Building for macOS" +} + +# Add output file flag based on specified output file name +$outputFlag = "-o=$OutputFileName" + +# collect flags +$buildFlags += @( + $tagsFlag, + $ldFlag, + $outputFlag +) + +function PrintFlags() { + param( + [string] $flags + ) + + # Attempt to format flags so that they are easily copy-pastable to be ran inside pwsh + $i = 0 + foreach ($buildFlag in $buildFlags) { + # If the flag has a value, wrap it in quotes. This is not required when invoking directly below, + # but when repasted into a shell for execution, the quotes can help escape special characters such as ','. + $argWithValue = $buildFlag.Split('=', 2) + if ($argWithValue.Length -eq 2 -and !$argWithValue[1].StartsWith("`"")) { + $buildFlag = "$($argWithValue[0])=`"$($argWithValue[1])`"" + } + + # Write each flag on a newline with '`' acting as the multiline separator + if ($i -eq $buildFlags.Length - 1) { + Write-Host " $buildFlag" + } + else { + Write-Host " $buildFlag ``" + } + $i++ + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +# Enable the loopvar experiment, which makes the loop variaible for go loops like `range` behave as most folks would expect. +# the go team is exploring making this default in the future, and we'd like to opt into the behavior now. +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + if ($BuildRecordMode) { + # Modify build tags to include record + $recordTagPatched = $false + for ($i = 0; $i -lt $buildFlags.Length; $i++) { + if ($buildFlags[$i].StartsWith("-tags=")) { + $buildFlags[$i] += ",record" + $recordTagPatched = $true + } + } + if (-not $recordTagPatched) { + $buildFlags += "-tags=record" + } + # Add output file flag for record mode + $recordOutput = "-o=$OutputFileName-record" + if ($IsWindows) { $recordOutput += ".exe" } + $buildFlags += $recordOutput + + Write-Host "Running: go build (record) ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build (record)" + exit $LASTEXITCODE + } + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} diff --git a/cli/azd/extensions/azure.ai.skills/ci-test.ps1 b/cli/azd/extensions/azure.ai.skills/ci-test.ps1 new file mode 100644 index 00000000000..2e66dea3138 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/ci-test.ps1 @@ -0,0 +1,27 @@ +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +if ($IsWindows) { + $gotestsumBinary += ".exe" +} +$gotestsum = Join-Path $gopath "bin" $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + # Use gotestsum for better output formatting and summary + & $gotestsum --format testname -- ./... -count=1 +} else { + # Fallback to go test if gotestsum is not installed + Write-Host "gotestsum not found, using go test..." -ForegroundColor Yellow + go test ./... -v -count=1 +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Tests failed with exit code: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 diff --git a/cli/azd/extensions/azure.ai.skills/cspell.yaml b/cli/azd/extensions/azure.ai.skills/cspell.yaml new file mode 100644 index 00000000000..a10ee426b12 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/cspell.yaml @@ -0,0 +1,13 @@ +import: ../../.vscode/cspell.yaml +words: + # Skill commands + - azureaiskills + - exterrors + - foundry + - foundrysdk + - orderby + - tarball + - zipslip + - gzip + - skill + - skills diff --git a/cli/azd/extensions/azure.ai.skills/extension.yaml b/cli/azd/extensions/azure.ai.skills/extension.yaml new file mode 100644 index 00000000000..e5464f117ce --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/extension.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=../extension.schema.json +id: azure.ai.skills +namespace: ai.skill +displayName: Foundry skills (Preview) +description: Manage Microsoft Foundry skills (reusable agent behavioral guidelines) from your terminal. (Preview) +usage: azd ai skill [options] +# NOTE: Make sure version.txt is in sync with this version. +version: 0.0.1-preview +requiredAzdVersion: ">1.23.13" +language: go +capabilities: + - custom-commands + - metadata +examples: + - name: list + description: List skills in the current Foundry project. + usage: azd ai skill list + - name: create + description: Create a skill from a SKILL.md file. + usage: azd ai skill create my-skill --file ./SKILL.md + - name: download + description: Download and extract a skill into ./.agents/skills/. + usage: azd ai skill download my-skill diff --git a/cli/azd/extensions/azure.ai.skills/go.mod b/cli/azd/extensions/azure.ai.skills/go.mod new file mode 100644 index 00000000000..d0d34e923d7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/go.mod @@ -0,0 +1,104 @@ +module azureaiskills + +go 1.26.1 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 + github.com/azure/azure-dev/cli/azd v1.24.3 + github.com/fatih/color v1.18.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + go.yaml.in/yaml/v3 v3.0.4 +) + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/braydonk/yaml v0.9.0 // indirect + github.com/buger/goterm v1.0.4 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/drone/envsubst v1.0.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jmespath-community/go-jmespath v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect + github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/azd/extensions/azure.ai.skills/go.sum b/cli/azd/extensions/azure.ai.skills/go.sum new file mode 100644 index 00000000000..d7d89e05563 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/go.sum @@ -0,0 +1,314 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 h1:0g4UTtvRA9goC37cmD9ZHdW6CCNJR4cOXBnHz0r4ubM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3/go.mod h1:fEiHi0sbYqbo3shUkIF1SNxm8GyeEJl+Poc/djOvbdE= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.24.3 h1:r2kEr2YYLu4ImKo6nR/WjhHg/1SliN1uwmAVqnM8t3o= +github.com/azure/azure-dev/cli/azd v1.24.3/go.mod h1:YANepMw36aWA8/mQyXau6JCAG84oK0ZgfvLF8rN5asU= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= +github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 h1:a5q2sWiet6kgqucSGjYN1jhT2cn4bMKUwprtm2IGRto= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= +github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go new file mode 100644 index 00000000000..522216332ff --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "io" + "log" + "os" + "strconv" + "time" + + azcorelog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log" + "github.com/spf13/pflag" +) + +// setupDebugLogging configures debug logging for the extension. +// +// When debug mode is disabled, the standard Go logger writes to io.Discard and +// the Azure SDK logger is silenced. When enabled, both write into +// `azd-ai-skills-.log` in the current working directory (or stderr if +// the file cannot be opened). +// +// The Azure SDK pipeline is configured with `Logging.IncludeBody: false` for +// every skill request — see `skill_api/client.go` and §11 of the design spec +// for the rationale (request bodies carry user-authored description / +// instructions, and there is no sanitizer in place yet). +// +// Returns a cleanup function that should be deferred by the caller. The +// extension currently discards the cleanup because log writes are unbuffered +// and the OS closes the file on exit; the cleanup is provided so callers that +// care can deterministically close the file. +func setupDebugLogging(flags *pflag.FlagSet) func() { + if !isDebug(flags) { + log.SetOutput(io.Discard) + azcorelog.SetListener(nil) + return func() {} + } + + currentDate := time.Now().Format("2006-01-02") + logFileName := fmt.Sprintf("azd-ai-skills-%s.log", currentDate) + + //nolint:gosec // log file name is generated locally from date and not user-controlled + logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + + var w io.Writer + var closeFile func() + if err != nil { + w = os.Stderr + closeFile = func() {} + } else { + w = logFile + closeFile = func() { _ = logFile.Close() } + } + + log.SetOutput(w) + azcorelog.SetListener(func(event azcorelog.Event, msg string) { + // Body logging is disabled in the pipeline, so msg never contains + // request/response bodies. Even so, never log raw skill content. + fmt.Fprintf(w, "[%s] %s: %s\n", time.Now().Format(time.RFC3339), event, msg) + }) + + return func() { + log.SetOutput(io.Discard) + azcorelog.SetListener(nil) + closeFile() + } +} + +// isDebug reports whether --debug is set on the command line or +// AZD_EXT_DEBUG is enabled in the environment. +func isDebug(flags *pflag.FlagSet) bool { + if flags != nil { + if v, err := flags.GetBool("debug"); err == nil && v { + return true + } + } + v, _ := strconv.ParseBool(os.Getenv("AZD_EXT_DEBUG")) + return v +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go new file mode 100644 index 00000000000..2a18a6f75be --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "log" + "os" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// endpointSource identifies where the resolved Foundry project endpoint came +// from. Used for telemetry and debug logging only — never echoed to the user. +type endpointSource string + +const ( + sourceFlag endpointSource = "flag" + sourceAzdEnv endpointSource = "azdEnv" + sourceGlobalConfig endpointSource = "globalConfig" + sourceFoundryEnv endpointSource = "foundryEnv" +) + +const ( + // skillsContextEndpointKey is the global-config path this extension owns. + skillsContextEndpointKey = "extensions.ai-skills.project.context.endpoint" + // agentsContextEndpointKey is the global-config path owned by + // `azure.ai.agents`. We read it as a fallback so users who configured the + // endpoint via the agents extension do not have to re-run `set`. + agentsContextEndpointKey = "extensions.ai-agents.project.context.endpoint" + // azdEnvKey is the active azd environment value that supplies the + // Foundry project endpoint. + azdEnvKey = "AZURE_AI_PROJECT_ENDPOINT" + // foundryEnvKey is the host environment variable that supplies the + // Foundry project endpoint as a last-resort fallback. + foundryEnvKey = "FOUNDRY_PROJECT_ENDPOINT" +) + +// resolveProjectEndpoint implements the 5-level cascade from the design spec. +// +// 1. flagEndpoint (from -p / --project-endpoint). +// 2. Active azd env value AZURE_AI_PROJECT_ENDPOINT. +// 3. Global config extensions.ai-skills.project.context.endpoint, falling +// back to extensions.ai-agents.project.context.endpoint. +// 4. Host env var FOUNDRY_PROJECT_ENDPOINT. +// 5. Structured error. +// +// The endpoint string is returned verbatim; URL validation is left to the +// caller (the existing agents extension validates against a Foundry-host +// suffix list, but the skills surface accepts any reachable HTTPS endpoint +// against the data plane, so we defer that check to the actual HTTP call). +func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, endpointSource, error) { + if flagEndpoint != "" { + return flagEndpoint, sourceFlag, nil + } + + // Levels 2 & 3 require the azd client. If azd is not running this + // extension as a child process (unlikely in practice), skip both and fall + // through to the host env var. + if azdClient, err := azdext.NewAzdClient(); err == nil { + defer azdClient.Close() + + // 2. Active azd env value. + if envResp, envErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); envErr == nil { + if valResp, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: azdEnvKey, + }); valErr == nil && valResp.Value != "" { + return valResp.Value, sourceAzdEnv, nil + } + } + + // 3. Global config: skills key first, then agents fallback. + if ch, chErr := azdext.NewConfigHelper(azdClient); chErr == nil { + for _, key := range []string{skillsContextEndpointKey, agentsContextEndpointKey} { + var endpoint string + if found, getErr := ch.GetUserJSON(ctx, key, &endpoint); getErr == nil && found && endpoint != "" { + if key == agentsContextEndpointKey { + log.Printf("resolveProjectEndpoint: using fallback global config key %q", agentsContextEndpointKey) + } + return endpoint, sourceGlobalConfig, nil + } + } + } + } + + // 4. Host env var. + if ep := os.Getenv(foundryEnvKey); ep != "" { + return ep, sourceFoundryEnv, nil + } + + // 5. Structured error. + return "", "", exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "pass --project-endpoint, set "+azdEnvKey+" in the active azd environment, "+ + "persist a workspace default with `azd ai agent project set `, "+ + "or export "+foundryEnvKey+" in your shell", + ) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go new file mode 100644 index 00000000000..72ea61778f7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveProjectEndpoint_FlagWins(t *testing.T) { + ep, src, err := resolveProjectEndpoint(context.Background(), "https://flag.example.com") + require.NoError(t, err) + require.Equal(t, "https://flag.example.com", ep) + require.Equal(t, sourceFlag, src) +} + +func TestResolveProjectEndpoint_HostEnvVar(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://host.example.com") + + ep, src, err := resolveProjectEndpoint(context.Background(), "") + require.NoError(t, err) + require.Equal(t, "https://host.example.com", ep) + require.Equal(t, sourceFoundryEnv, src) +} + +func TestResolveProjectEndpoint_MissingAll(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + + _, _, err := resolveProjectEndpoint(context.Background(), "") + require.Error(t, err) + require.Contains(t, err.Error(), "no Foundry project endpoint resolved") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go new file mode 100644 index 00000000000..cfce2c1b985 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "azureaiskills/internal/pkg/skill_api" +) + +const ( + outputJSON = "json" + outputTable = "table" +) + +// printJSON marshals v to indented JSON on stdout. Returns any marshaling +// error so the caller can surface it to the user. +func printJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +// printSkillDetail renders one skill in either JSON (passthrough) or table +// (key/value) form. +func printSkillDetail(s *skill_api.Skill, format string) error { + if format == outputJSON { + return printJSON(s) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + defer tw.Flush() + fmt.Fprintf(tw, "Name\t%s\n", s.Name) + if s.SkillID != "" { + fmt.Fprintf(tw, "Skill ID\t%s\n", s.SkillID) + } + if s.Description != "" { + fmt.Fprintf(tw, "Description\t%s\n", s.Description) + } + fmt.Fprintf(tw, "Has Blob\t%t\n", s.HasBlob) + if len(s.Metadata) > 0 { + fmt.Fprintln(tw, "Metadata\t") + for k, v := range s.Metadata { + fmt.Fprintf(tw, " %s\t%s\n", k, v) + } + } + return nil +} + +// printSkillList renders a flat slice of skills. +func printSkillList(items []skill_api.Skill, format string) error { + if format == outputJSON { + if items == nil { + items = []skill_api.Skill{} + } + return printJSON(items) + } + return writeSkillTable(os.Stdout, items) +} + +func writeSkillTable(w io.Writer, items []skill_api.Skill) error { + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + defer tw.Flush() + fmt.Fprintln(tw, "NAME\tDESCRIPTION\tHAS BLOB") + fmt.Fprintln(tw, "----\t-----------\t--------") + for _, s := range items { + fmt.Fprintf(tw, "%s\t%s\t%t\n", s.Name, truncate(s.Description, 60), s.HasBlob) + } + return nil +} + +func truncate(s string, max int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= max { + return s + } + if max < 3 { + return s[:max] + } + return s[:max-3] + "..." +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go new file mode 100644 index 00000000000..32d7f6460d8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "azureaiskills/internal/pkg/skill_api" + + "github.com/stretchr/testify/require" +) + +func TestWriteSkillTable(t *testing.T) { + var buf bytes.Buffer + skills := []skill_api.Skill{ + {Name: "alpha", Description: "first skill", HasBlob: false}, + {Name: "bravo", Description: "second skill with quite a long description that should be truncated", HasBlob: true}, + } + require.NoError(t, writeSkillTable(&buf, skills)) + + out := buf.String() + require.True(t, strings.Contains(out, "NAME"), out) + require.True(t, strings.Contains(out, "alpha"), out) + require.True(t, strings.Contains(out, "bravo"), out) + // Long description is truncated. + require.True(t, strings.Contains(out, "..."), out) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go new file mode 100644 index 00000000000..d42160f2c05 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// NewRootCommand builds the `azd ai skill` root command and its subcommand +// graph. The cobra root is constructed by [azdext.NewExtensionRootCommand] +// so that azd's global flags (`--debug`, `--no-prompt`, `--cwd`, +// `-e/--environment`, `--output`) are pre-registered. +// +// The cobra `Name` is `skill`. The extension's namespace `ai.skill` maps it +// under the existing `azd ai` group at install time. +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "skill", + Use: "skill [options]", + Short: fmt.Sprintf("Manage Foundry skills from your terminal. %s", color.YellowString("(Preview)")), + Long: `Manage Foundry skills — reusable behavioral guidelines an agent can attach +at runtime — from your terminal. + +Skills carry either inline JSON (description + Markdown instructions) or a +packaged gzip tarball bundling SKILL.md plus any sibling assets. Use this +command group to create, update, show, list, download, and delete skills in +a Foundry project.`, + }) + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.DisableDefaultCmd = true + + // Configure debug logging once on the root command so every subcommand + // inherits it. The cleanup func is intentionally discarded: log writes + // are unbuffered and the OS closes the file on exit. + sdkPreRun := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if sdkPreRun != nil { + if err := sdkPreRun(cmd, args); err != nil { + return err + } + } + setupDebugLogging(cmd.Flags()) + return nil + } + + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + // Register -p / --project-endpoint as a persistent flag so all subcommands + // inherit it without redeclaring. + rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", + "Foundry project endpoint URL (overrides env vars and global config)") + + // Standard extension commands. + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + rootCmd.AddCommand(newVersionCommand()) + rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.skills", func() *cobra.Command { + return rootCmd + })) + + // Skill subcommands. + rootCmd.AddCommand(newCreateCommand(extCtx)) + rootCmd.AddCommand(newUpdateCommand(extCtx)) + rootCmd.AddCommand(newShowCommand(extCtx)) + rootCmd.AddCommand(newListCommand(extCtx)) + rootCmd.AddCommand(newDownloadCommand(extCtx)) + rootCmd.AddCommand(newDeleteCommand(extCtx)) + + return rootCmd +} + +// configureExtensionHost is the `listen` configuration callback. Skills do not +// need to register any lifecycle hooks, so the callback is a no-op. +func configureExtensionHost(host *azdext.ExtensionHost) { + _ = host +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go new file mode 100644 index 00000000000..9bbfd16d4d6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + "azureaiskills/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// skillContext bundles the resolved REST client + endpoint information for a +// single command invocation. All actions resolve this once at the top of +// their Run() method. +type skillContext struct { + client *skill_api.Client + endpoint string + source endpointSource +} + +// resolveSkillContext resolves the project endpoint via the standard cascade +// and creates a Foundry Skills REST client. +func resolveSkillContext(ctx context.Context, flagEndpoint string) (*skillContext, error) { + endpoint, src, err := resolveProjectEndpoint(ctx, flagEndpoint) + if err != nil { + return nil, err + } + + cred, err := newCredential() + if err != nil { + return nil, err + } + + client := skill_api.NewClient(endpoint, cred, version.Version) + return &skillContext{ + client: client, + endpoint: endpoint, + source: src, + }, nil +} + +// newCredential creates the Azure credential used by every skill API call. +// Uses the azd-managed `azd auth login` token when available. +func newCredential() (azcore.TokenCredential, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run `azd auth login` to authenticate", + ) + } + return cred, nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go new file mode 100644 index 00000000000..84ffd6adb99 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// createFlags holds parsed input for the `skill create` command. +type createFlags struct { + name string + description string + instructions string + file string + force bool + noPrompt bool + output string + projectEndpoint string + + descriptionSet bool + instructionsSet bool +} + +// createAction is the create-command implementation. +type createAction struct { + flags *createFlags +} + +// Run executes the create operation. +func (a *createAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + mode, err := selectCreateMode(a.flags) + if err != nil { + return err + } + + // Resolve interactive prompts (mode==modeNone with prompting available) + // before doing any IO so the user sees a fast validation error if neither + // inline nor file mode was supplied with --no-prompt. + if mode == modeNone { + if a.flags.noPrompt { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no input supplied to skill create", + "pass --description and --instructions, or --file ", + ) + } + if err := promptForInline(ctx, a.flags); err != nil { + return err + } + mode = modeInline + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + // Honor --force by deleting the existing skill first. + if a.flags.force { + if _, delErr := skillCtx.client.Delete(ctx, a.flags.name); delErr != nil { + // Only swallow 404 — anything else means the upcoming create would + // likely fail too, so surface it now. + if !isNotFound(delErr) { + return exterrors.ServiceFromAzure(delErr, exterrors.OpDeleteSkill) + } + } + } + + switch mode { + case modeInline: + return a.runInline(ctx, skillCtx.client) + case modeFileMd: + return a.runFileMd(ctx, skillCtx.client) + case modeFilePackage: + return a.runFilePackage(ctx, skillCtx.client) + } + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "unsupported create mode", + "this is a bug; please file an issue", + ) +} + +func (a *createAction) runInline(ctx context.Context, client *skill_api.Client) error { + if strings.TrimSpace(a.flags.description) == "" { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "inline mode requires --description", + "pass --description and --instructions, or use --file ", + ) + } + if strings.TrimSpace(a.flags.instructions) == "" { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "inline mode requires --instructions", + "pass --description and --instructions, or use --file ", + ) + } + + created, err := client.CreateInline(ctx, skill_api.CreateRequest{ + Name: a.flags.name, + Description: a.flags.description, + Instructions: a.flags.instructions, + }) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return printCreateResult(created, a.flags.output) +} + +func (a *createAction) runFileMd(ctx context.Context, client *skill_api.Client) error { + data, err := readFileWithLimit(a.flags.file) + if err != nil { + return err + } + parsed, parseErr := skill_api.ParseSkillMd(data) + if parseErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("failed to parse %s: %s", a.flags.file, parseErr), + "ensure the file begins with a YAML front matter block delimited by '---'", + ) + } + + if parsed.Name != "" && parsed.Name != a.flags.name && !shouldSuppressWarning(a.flags.noPrompt, a.flags.output) { + fmt.Fprintf(os.Stderr, + "Warning: SKILL.md front matter `name: %q` does not match positional argument %q; using %q\n", + parsed.Name, a.flags.name, a.flags.name, + ) + } + + req := skill_api.CreateRequest{ + Name: a.flags.name, + Description: parsed.Description, + Instructions: parsed.Instructions, + Metadata: parsed.Metadata, + } + created, err := client.CreateInline(ctx, req) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return printCreateResult(created, a.flags.output) +} + +func (a *createAction) runFilePackage(ctx context.Context, client *skill_api.Client) error { + info, statErr := os.Stat(a.flags.file) + if statErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot stat %s: %s", a.flags.file, statErr), + "verify the path exists and is readable", + ) + } + if info.IsDir() { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("--file %s is a directory; expected a .tar.gz / .tgz archive", a.flags.file), + "pass a single gzip archive path", + ) + } + + f, openErr := os.Open(a.flags.file) //nolint:gosec // path supplied by the user, opened for the user + if openErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot open %s: %s", a.flags.file, openErr), + "verify the file is readable", + ) + } + defer f.Close() + + created, err := client.CreatePackage(ctx, f, info.Size()) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return printCreateResult(created, a.flags.output) +} + +func printCreateResult(s *skill_api.Skill, format string) error { + if format == outputJSON { + return printJSON(s) + } + fmt.Printf("Skill %q created.\n", s.Name) + return printSkillDetail(s, outputTable) +} + +// --- mode selection --- + +type createMode int + +const ( + modeNone createMode = iota + modeInline + modeFileMd + modeFilePackage +) + +func selectCreateMode(f *createFlags) (createMode, error) { + inlineProvided := f.descriptionSet || f.instructionsSet + fileProvided := f.file != "" + + if inlineProvided && fileProvided { + return modeNone, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--file is mutually exclusive with --description / --instructions", + "pass only one of: inline flags, --file ", + ) + } + + if fileProvided { + ext := strings.ToLower(filepath.Ext(f.file)) + // `.tgz` and `.tar.gz` both work for packages; `.md` for inline. + switch { + case ext == ".md": + return modeFileMd, nil + case ext == ".tgz": + return modeFilePackage, nil + case strings.HasSuffix(strings.ToLower(f.file), ".tar.gz"): + return modeFilePackage, nil + default: + return modeNone, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("unsupported --file extension %q", ext), + "use .md for inline metadata, or .tar.gz / .tgz for a package upload", + ) + } + } + + if inlineProvided { + return modeInline, nil + } + return modeNone, nil +} + +func promptForInline(ctx context.Context, f *createFlags) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no input supplied to skill create", + "pass --description and --instructions, or --file ", + ) + } + defer azdClient.Close() + + if strings.TrimSpace(f.description) == "" { + resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Skill description", + HelpMessage: "A short human-readable summary of what this skill " + + "does. Sent to the service as `description`.", + Required: true, + }, + }) + if promptErr != nil { + return promptErr + } + f.description = resp.Value + f.descriptionSet = true + } + + if strings.TrimSpace(f.instructions) == "" { + resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Skill instructions (Markdown body)", + HelpMessage: "The Markdown body that defines the skill's " + + "behavior. Sent to the service as `instructions`.", + Required: true, + }, + }) + if promptErr != nil { + return promptErr + } + f.instructions = resp.Value + f.instructionsSet = true + } + + return nil +} + +// newCreateCommand constructs the `skill create` Cobra command. +func newCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &createFlags{} + action := &createAction{flags: flags} + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new Foundry skill.", + Long: `Create a new Foundry skill in one of three mutually exclusive modes: + + 1. Inline: --description "..." --instructions "..." + 2. SKILL.md: --file ./SKILL.md (CLI parses YAML front matter + body) + 3. Package: --file ./skill.tar.gz (CLI uploads the archive as-is) + +Pass --force to delete an existing skill of the same name before creating.`, + Example: ` azd ai skill create greet-user --description "Welcomes a new user" --instructions "Greet ..." + azd ai skill create greet-user --file ./SKILL.md + azd ai skill create greet-user --file ./skill.tar.gz --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + flags.descriptionSet = cmd.Flags().Changed("description") + flags.instructionsSet = cmd.Flags().Changed("instructions") + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + + ctx := azdext.WithAccessToken(cmd.Context()) + return action.Run(ctx) + }, + } + + cmd.Flags().StringVar(&flags.description, "description", "", + "Inline mode: human-readable summary of the skill") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", + "Inline mode: Markdown body defining skill behavior") + cmd.Flags().StringVar(&flags.file, "file", "", + "Path to SKILL.md (.md) or a gzip package (.tar.gz / .tgz)") + cmd.Flags().BoolVar(&flags.force, "force", false, + "Delete an existing skill of the same name before creating") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, + }) + return cmd +} + +// --- shared helpers --- + +// readFileWithLimit reads up to 1 MiB from path. SKILL.md should be well under +// the service's 100 KiB+1 KiB description/instruction caps, so 1 MiB is a +// generous bound that still defends against accidentally reading a giant file. +func readFileWithLimit(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot stat %s: %s", path, err), + "verify the path exists and is readable", + ) + } + if info.IsDir() { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("--file %s is a directory; expected a SKILL.md file", path), + "pass a single .md file", + ) + } + const maxBytes = 1 << 20 + if info.Size() > maxBytes { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("%s exceeds the 1 MiB SKILL.md size limit (got %d bytes)", path, info.Size()), + "split the file into smaller assets and use a package upload", + ) + } + data, err := os.ReadFile(path) //nolint:gosec // user-supplied path read on their behalf + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot read %s: %s", path, err), + "verify the file is readable", + ) + } + return data, nil +} + +// shouldSuppressWarning reports whether interactive warnings (e.g., front +// matter name mismatch) should be suppressed. +func shouldSuppressWarning(noPrompt bool, format string) bool { + return noPrompt || format == outputJSON +} + +// isNotFound reports whether err looks like an HTTP 404 from the service. +func isNotFound(err error) bool { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + return respErr.StatusCode == 404 + } + return false +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go new file mode 100644 index 00000000000..dde68399b01 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// deleteFlags holds parsed input for the `skill delete` command. +type deleteFlags struct { + name string + force bool + noPrompt bool + output string + projectEndpoint string +} + +// deleteAction is the delete-command implementation. +type deleteAction struct { + flags *deleteFlags +} + +// deleteResult is the JSON shape printed when --output=json. Cancelled +// deletions are represented as `{ deleted: false, cancelled: true, name }`. +type deleteResult struct { + Name string `json:"name"` + Deleted bool `json:"deleted"` + Cancelled bool `json:"cancelled,omitempty"` +} + +// Run executes the delete operation. +func (a *deleteAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + if !a.flags.force { + if a.flags.noPrompt { + return exterrors.Validation( + exterrors.CodeMissingForceFlag, + fmt.Sprintf("deleting %q requires confirmation", a.flags.name), + "pass --force to skip confirmation in non-interactive mode", + ) + } + + confirmed, err := a.confirmDelete(ctx) + if err != nil { + return err + } + if !confirmed { + return a.printResult(deleteResult{ + Name: a.flags.name, + Deleted: false, + Cancelled: true, + }) + } + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + if _, err := skillCtx.client.Delete(ctx, a.flags.name); err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteSkill) + } + + return a.printResult(deleteResult{Name: a.flags.name, Deleted: true}) +} + +func (a *deleteAction) confirmDelete(ctx context.Context) (bool, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return false, fmt.Errorf("failed to create azd client for confirmation: %w", err) + } + defer azdClient.Close() + + defaultValue := false + resp, err := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("Delete skill %q?", a.flags.name), + DefaultValue: &defaultValue, + }, + }) + if err != nil { + return false, err + } + if resp.Value == nil { + return false, nil + } + return *resp.Value, nil +} + +func (a *deleteAction) printResult(res deleteResult) error { + if a.flags.output == outputJSON { + return printJSON(res) + } + if res.Cancelled { + fmt.Printf("Skill %q deletion cancelled.\n", res.Name) + } else { + fmt.Printf("Skill %q deleted.\n", res.Name) + } + return nil +} + +// newDeleteCommand constructs the `skill delete` Cobra command. +func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &deleteFlags{} + action := &deleteAction{flags: flags} + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Foundry skill.", + Long: `Delete a skill from the resolved Foundry project. + +By default the CLI prompts for confirmation. Pass --force to skip the prompt. +In --no-prompt mode (set globally), --force is required.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + + ctx := azdext.WithAccessToken(cmd.Context()) + return action.Run(ctx) + }, + } + + cmd.Flags().BoolVar(&flags.force, "force", false, + "Skip the confirmation prompt") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go new file mode 100644 index 00000000000..f831026108e --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// downloadFlags holds parsed input for the `skill download` command. +type downloadFlags struct { + name string + outputDir string + raw bool + force bool + output string + projectEndpoint string + + outputDirSet bool +} + +// downloadAction is the download-command implementation. +type downloadAction struct { + flags *downloadFlags +} + +// downloadResult is the JSON shape printed when --output=json. The shape is +// part of the published contract: callers depend on it. +type downloadResult struct { + Skill string `json:"skill"` + OutputDir string `json:"outputDir"` + Files []string `json:"files,omitempty"` + Archive string `json:"archive,omitempty"` + Raw bool `json:"raw"` +} + +// Run executes the download operation. +func (a *downloadAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + outputDir := a.flags.outputDir + if outputDir == "" { + outputDir = filepath.Join(".agents", "skills", a.flags.name) + } + absOut, err := filepath.Abs(outputDir) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid --output-dir: %s", err), + "pass a valid filesystem path", + ) + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + body, err := skillCtx.client.Download(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpDownloadSkill) + } + defer body.Close() + + if a.flags.raw { + return a.writeRaw(body, absOut) + } + return a.writeExtracted(body, absOut) +} + +func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error { + if err := os.MkdirAll(outputDir, 0700); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + archivePath := filepath.Join(outputDir, a.flags.name+".tar.gz") + + if !a.flags.force { + if _, statErr := os.Lstat(archivePath); statErr == nil { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s already exists", archivePath), + "pass --force to overwrite", + ) + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", archivePath, statErr) + } + } + + // O_TRUNC ensures --force overwrites cleanly. + //nolint:gosec // archivePath is built from user-supplied --output-dir + skill name, written on user behalf + f, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("create archive: %w", err) + } + if _, copyErr := io.Copy(f, body); copyErr != nil { + _ = f.Close() + return fmt.Errorf("write archive: %w", copyErr) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close archive: %w", err) + } + + res := downloadResult{ + Skill: a.flags.name, + OutputDir: outputDir, + Archive: a.flags.name + ".tar.gz", + Raw: true, + } + return a.printResult(res) +} + +func (a *downloadAction) writeExtracted(body io.Reader, outputDir string) error { + result, err := skill_api.SafeExtract(body, skill_api.ExtractOptions{ + OutputDir: outputDir, + Force: a.flags.force, + }) + if err != nil { + return classifyExtractError(err, outputDir) + } + + res := downloadResult{ + Skill: a.flags.name, + OutputDir: outputDir, + Files: result.Files, + Raw: false, + } + return a.printResult(res) +} + +func (a *downloadAction) printResult(res downloadResult) error { + if a.flags.output == outputJSON { + return printJSON(res) + } + if res.Raw { + fmt.Printf("Skill %q downloaded to %s\n", res.Skill, filepath.Join(res.OutputDir, res.Archive)) + } else { + fmt.Printf("Skill %q extracted into %s (%d files)\n", res.Skill, res.OutputDir, len(res.Files)) + for _, name := range res.Files { + fmt.Printf(" %s\n", name) + } + } + return nil +} + +// classifyExtractError converts a SafeExtract error into a structured +// extension error when possible. Unknown errors propagate unchanged so the +// gRPC layer surfaces them with a default Internal category. +func classifyExtractError(err error, outputDir string) error { + switch { + case errors.Is(err, skill_api.ErrUnsafeEntry): + return exterrors.Validation( + exterrors.CodeSkillArchiveUnsafe, + err.Error(), + "the downloaded archive contains an unsafe entry; do not extract it", + ) + case errors.Is(err, skill_api.ErrLimitExceeded): + return exterrors.Validation( + exterrors.CodeSkillArchiveUnsafe, + err.Error(), + "the archive exceeds the per-skill decompression safety limit", + ) + case errors.Is(err, skill_api.ErrCollision): + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + err.Error(), + fmt.Sprintf("pass --force to overwrite existing files in %s", outputDir), + ) + case errors.Is(err, skill_api.ErrInvalidGzip): + return exterrors.Validation( + exterrors.CodeInvalidParameter, + err.Error(), + "the service did not return a gzip archive; retry or contact support", + ) + } + return err +} + +// newDownloadCommand constructs the `skill download` Cobra command. +func newDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &downloadFlags{} + action := &downloadAction{flags: flags} + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download a Foundry skill package.", + Long: `Download a skill's gzip package. + +By default the CLI extracts the archive into --output-dir (which defaults to +'./.agents/skills//'). Pass --raw to write the unmodified gzip archive +into --output-dir instead. + +Extraction enforces strict safety rules: no absolute paths, no '..' segments, +no symlinks, no hard links, no non-regular files, and a 10,000-entry / +512 MB cap on the total uncompressed size.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.outputDirSet = cmd.Flags().Changed("output-dir") + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + + ctx := azdext.WithAccessToken(cmd.Context()) + return action.Run(ctx) + }, + } + + cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", + "Directory to write the extracted skill (default: ./.agents/skills//)") + cmd.Flags().BoolVar(&flags.raw, "raw", false, + "Skip extraction; write the gzip archive as-is to --output-dir") + cmd.Flags().BoolVar(&flags.force, "force", false, + "Overwrite existing files in --output-dir") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go new file mode 100644 index 00000000000..67891b07fdc --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// listFlags holds parsed input for the `skill list` command. +type listFlags struct { + top int + orderBy string + output string + projectEndpoint string +} + +// listAction is the list-command implementation. +type listAction struct { + flags *listFlags +} + +// Run executes the list operation. +func (a *listAction) Run(ctx context.Context) error { + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + items, err := skillCtx.client.ListAll( + ctx, + skill_api.ListOptions{ + Top: a.flags.top, + OrderBy: a.flags.orderBy, + }, + a.flags.top, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpListSkills) + } + + return printSkillList(items, a.flags.output) +} + +// newListCommand constructs the `skill list` Cobra command. +func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &listFlags{} + action := &listAction{flags: flags} + + cmd := &cobra.Command{ + Use: "list", + Short: "List Foundry skills in the project.", + Long: `List skills in the resolved Foundry project. + +Without --top, the CLI iterates all pages transparently into one flat list. +With --top, the CLI stops once that many items have been collected.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags.output = extCtx.OutputFormat + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + + ctx := azdext.WithAccessToken(cmd.Context()) + return action.Run(ctx) + }, + } + + cmd.Flags().IntVar(&flags.top, "top", 0, + "Return up to N skills (default: all)") + cmd.Flags().StringVar(&flags.orderBy, "orderby", "", + "Sort order forwarded to the service (e.g. 'asc' or 'desc')") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go new file mode 100644 index 00000000000..a0d61f89db2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// showFlags holds parsed input for the `skill show` command. +type showFlags struct { + name string + output string + projectEndpoint string +} + +// showAction is the show-command implementation. +type showAction struct { + flags *showFlags +} + +// Run executes the show operation. +func (a *showAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + s, err := skillCtx.client.Get(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) + } + + return printSkillDetail(s, a.flags.output) +} + +// newShowCommand constructs the `skill show` Cobra command. +func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &showFlags{} + action := &showAction{flags: flags} + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show metadata for a Foundry skill.", + Long: `Show the metadata returned by the service for a skill. + +This command returns metadata only. To retrieve the skill body, use +'azd ai skill download '.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + + ctx := azdext.WithAccessToken(cmd.Context()) + return action.Run(ctx) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go new file mode 100644 index 00000000000..2e55b94fac4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// updateFlags holds parsed input for the `skill update` command. +type updateFlags struct { + name string + description string + instructions string + file string + output string + projectEndpoint string + + descriptionSet bool + instructionsSet bool +} + +// updateAction is the update-command implementation. +type updateAction struct { + flags *updateFlags +} + +// Run executes the update operation. +func (a *updateAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + if err := a.validateFlags(); err != nil { + return err + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + // GET-merge-POST so the user can update a single field without losing + // the others. + current, err := skillCtx.client.Get(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) + } + + req := skill_api.UpdateRequest{ + Description: current.Description, + Metadata: current.Metadata, + } + + // Apply inline overrides. + if a.flags.descriptionSet { + req.Description = a.flags.description + } + if a.flags.instructionsSet { + req.Instructions = a.flags.instructions + } + + // Apply --file (SKILL.md only) overrides. + if a.flags.file != "" { + data, readErr := readFileWithLimit(a.flags.file) + if readErr != nil { + return readErr + } + parsed, parseErr := skill_api.ParseSkillMd(data) + if parseErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("failed to parse %s: %s", a.flags.file, parseErr), + "ensure the file begins with a YAML front matter block delimited by '---'", + ) + } + if parsed.Description != "" { + req.Description = parsed.Description + } + if parsed.Instructions != "" { + req.Instructions = parsed.Instructions + } + if len(parsed.Metadata) > 0 { + req.Metadata = parsed.Metadata + } + } + + updated, err := skillCtx.client.Update(ctx, a.flags.name, req) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpUpdateSkill) + } + + if a.flags.output == outputJSON { + return printJSON(updated) + } + fmt.Printf("Skill %q updated.\n", updated.Name) + return printSkillDetail(updated, outputTable) +} + +// validateFlags enforces the update-specific flag rules. +func (a *updateAction) validateFlags() error { + inlineProvided := a.flags.descriptionSet || a.flags.instructionsSet + fileProvided := a.flags.file != "" + + if !inlineProvided && !fileProvided { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no fields to update", + "pass --description, --instructions, and/or --file ", + ) + } + if inlineProvided && fileProvided { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--file is mutually exclusive with --description / --instructions on update", + "pass either inline flags or --file , not both", + ) + } + + if fileProvided { + ext := strings.ToLower(filepath.Ext(a.flags.file)) + switch { + case ext == ".md": + return nil + case ext == ".tgz", strings.HasSuffix(strings.ToLower(a.flags.file), ".tar.gz"): + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + "gzip packages cannot be applied via `skill update`", + "use `azd ai skill create --file .tar.gz --force` to replace the skill", + ) + default: + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("unsupported --file extension %q on update", ext), + "update only accepts .md files", + ) + } + } + return nil +} + +// newUpdateCommand constructs the `skill update` Cobra command. +func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &updateFlags{} + action := &updateAction{flags: flags} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing Foundry skill.", + Long: `Update an existing skill's description, instructions, or metadata. + +Pass any subset of: + --description "..." --instructions "..." +or: + --file ./SKILL.md (parsed locally; .tar.gz / .tgz is not accepted here) + +The CLI fetches the current skill, merges your changes locally, then POSTs the +merged payload to the service.`, + Example: ` azd ai skill update my-skill --description "Updated summary" + azd ai skill update my-skill --file ./SKILL.md`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.descriptionSet = cmd.Flags().Changed("description") + flags.instructionsSet = cmd.Flags().Changed("instructions") + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + + ctx := azdext.WithAccessToken(cmd.Context()) + return action.Run(ctx) + }, + } + + cmd.Flags().StringVar(&flags.description, "description", "", + "New human-readable summary") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", + "New Markdown instructions body") + cmd.Flags().StringVar(&flags.file, "file", "", + "Path to a SKILL.md file whose values override the current skill") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go new file mode 100644 index 00000000000..56305fb006d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "regexp" + "strings" + + "azureaiskills/internal/exterrors" +) + +// skillNamePattern is the fallback skill name regex used when the service +// does not publish a separate constraint. Matches `agent_yaml.ValidateAgentName` +// in `azure.ai.agents` so users see one consistent rule across resource kinds. +// +// - Must start with an alphanumeric character. +// - May contain alphanumerics and hyphens in the middle. +// - Must end with an alphanumeric character (when more than 1 character). +// - Length 1-63 (matches the service's @maxLength on `name`). +var skillNamePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + +// validateSkillName returns a structured validation error when name does not +// satisfy [skillNamePattern]. The service has the final say; this function is +// a fast-fail guard to avoid round-tripping obviously invalid names. +func validateSkillName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + "skill name must not be empty", + "pass a non-empty argument", + ) + } + if !skillNamePattern.MatchString(trimmed) { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + fmt.Sprintf("skill name %q is invalid", trimmed), + "use 1-63 alphanumeric characters; hyphens are allowed only in the middle", + ) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go new file mode 100644 index 00000000000..3f24b8f1ca8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestValidateSkillName(t *testing.T) { + cases := []struct { + name string + wantErr bool + }{ + {"a", false}, + {"my-skill", false}, + {"abc123", false}, + {"Skill1-2-3", false}, + {"", true}, + {" ", true}, + {"-leading-hyphen", true}, + {"trailing-hyphen-", true}, + {"under_score", true}, + {"contains space", true}, + // 63 char limit + {string(makeRune('a', 63)), false}, + {string(makeRune('a', 64)), true}, + } + for _, c := range cases { + err := validateSkillName(c.name) + if c.wantErr { + require.Errorf(t, err, "expected validation error for %q", c.name) + var le *azdext.LocalError + require.True(t, errors.As(err, &le), "expected LocalError for %q", c.name) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) + } else { + require.NoErrorf(t, err, "unexpected error for %q", c.name) + } + } +} + +func makeRune(c rune, n int) []rune { + out := make([]rune, n) + for i := range out { + out[i] = c + } + return out +} + +func TestSelectCreateMode_ConflictingArgs(t *testing.T) { + _, err := selectCreateMode(&createFlags{ + descriptionSet: true, + file: "./SKILL.md", + }) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeConflictingArguments, le.Code) +} + +func TestSelectCreateMode_FileMd(t *testing.T) { + mode, err := selectCreateMode(&createFlags{file: "./SKILL.md"}) + require.NoError(t, err) + require.Equal(t, modeFileMd, mode) +} + +func TestSelectCreateMode_FilePackage(t *testing.T) { + for _, f := range []string{"./pkg.tar.gz", "./pkg.tgz", "./PKG.TGZ"} { + mode, err := selectCreateMode(&createFlags{file: f}) + require.NoError(t, err, "file %q", f) + require.Equal(t, modeFilePackage, mode, "file %q", f) + } +} + +func TestSelectCreateMode_UnknownExtension(t *testing.T) { + _, err := selectCreateMode(&createFlags{file: "./SKILL.txt"}) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestSelectCreateMode_InlineOnly(t *testing.T) { + mode, err := selectCreateMode(&createFlags{descriptionSet: true}) + require.NoError(t, err) + require.Equal(t, modeInline, mode) +} + +func TestSelectCreateMode_None(t *testing.T) { + mode, err := selectCreateMode(&createFlags{}) + require.NoError(t, err) + require.Equal(t, modeNone, mode) +} + +func TestUpdateAction_RequiresInput(t *testing.T) { + a := &updateAction{flags: &updateFlags{}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingRequiredField, le.Code) +} + +func TestUpdateAction_ConflictingArgs(t *testing.T) { + a := &updateAction{flags: &updateFlags{descriptionSet: true, file: "./SKILL.md"}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeConflictingArguments, le.Code) +} + +func TestUpdateAction_RejectsGzipFile(t *testing.T) { + for _, f := range []string{"./pkg.tar.gz", "./pkg.tgz"} { + a := &updateAction{flags: &updateFlags{file: f}} + err := a.validateFlags() + require.Errorf(t, err, "file %q", f) + var le *azdext.LocalError + require.True(t, errors.As(err, &le), "file %q", f) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code, "file %q", f) + } +} + +func TestUpdateAction_AcceptsMdFile(t *testing.T) { + a := &updateAction{flags: &updateFlags{file: "./SKILL.md"}} + require.NoError(t, a.validateFlags()) +} + +func TestUpdateAction_UnknownExtension(t *testing.T) { + a := &updateAction{flags: &updateFlags{file: "./SKILL.txt"}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestShouldSuppressWarning(t *testing.T) { + require.True(t, shouldSuppressWarning(true, outputTable)) + require.True(t, shouldSuppressWarning(false, outputJSON)) + require.False(t, shouldSuppressWarning(false, outputTable)) +} + +func TestTruncate(t *testing.T) { + require.Equal(t, "hello", truncate("hello", 10)) + require.Equal(t, "hi", truncate("hi", 2)) + require.Equal(t, "hel...", truncate("hello-world", 6)) + require.Equal(t, "hello world", truncate("hello\nworld", 60)) +} + +func TestIsNotFound(t *testing.T) { + require.False(t, isNotFound(nil)) + require.False(t, isNotFound(errors.New("oops"))) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go new file mode 100644 index 00000000000..fac873158f7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "azureaiskills/internal/version" + + "github.com/spf13/cobra" +) + +// newVersionCommand returns a `version` subcommand that prints the extension +// version, commit, and build date populated at build time via ldflags. +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the extension version.", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s\nCommit: %s\nBuild Date: %s\n", version.Version, version.Commit, version.BuildDate) + }, + } +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go new file mode 100644 index 00000000000..192bd112d2c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// Error codes for skill validation. +// +// These are usually paired with [Validation] when user input, files, +// or option combinations fail validation specific to the skill commands. +const ( + // CodeInvalidSkillName is used when does not match the + // service-documented (or fallback) skill-name regex. + CodeInvalidSkillName = "invalid_skill_name" + // CodeInvalidSkillFile is used when --file points to a missing, + // unreadable, or unsupported file, or when SKILL.md front matter + // fails to parse. + CodeInvalidSkillFile = "invalid_skill_file" + // CodeSkillArchiveUnsafe is used when a downloaded gzip archive + // contains an unsafe entry (zip-slip, symlink, oversized, etc.). + CodeSkillArchiveUnsafe = "skill_archive_unsafe" + // CodeSkillOutputCollision is used when `skill download` would + // overwrite an existing file and --force was not supplied. + CodeSkillOutputCollision = "skill_output_collision" +) + +// Error codes shared across the extension surface. +const ( + CodeConflictingArguments = "conflicting_arguments" + CodeInvalidParameter = "invalid_parameter" + CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeMissingRequiredField = "missing_required_field" + CodeMissingForceFlag = "missing_force_flag" + CodeSkillNotFound = "skill_not_found" + CodeSkillAlreadyExists = "skill_already_exists" +) + +// Error codes for auth. +const ( + //nolint:gosec // error code identifier, not a credential + CodeCredentialCreationFailed = "credential_creation_failed" +) + +// Operation names for [ServiceFromAzure] errors. +// These are prefixed to the Azure error code (e.g., "create_skill.NotFound"). +const ( + OpCreateSkill = "create_skill" + OpUpdateSkill = "update_skill" + OpDeleteSkill = "delete_skill" + OpGetSkill = "get_skill" + OpListSkills = "list_skills" + OpDownloadSkill = "download_skill" +) diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go new file mode 100644 index 00000000000..8f127151995 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error factories for the +// azure.ai.skills extension. The factories produce *azdext.LocalError or +// *azdext.ServiceError values that the azd host renders consistently across +// extensions and uses to emit stable telemetry. +package exterrors + +import ( + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation error for user-input or flag errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency error for missing resources or services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an auth error for authentication or authorization failures. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// ServiceFromAzure converts an Azure SDK error into a structured service error. +// Non-azcore errors are returned unchanged. +func ServiceFromAzure(err error, operation string) error { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + return &azdext.ServiceError{ + Message: respErr.Error(), + ErrorCode: respErr.ErrorCode, + StatusCode: respErr.StatusCode, + ServiceName: operation, + } + } + return err +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go new file mode 100644 index 00000000000..ec87e042d0c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// Default archive-safety limits. The spec caps decompression at 10,000 entries +// and 512 MB total uncompressed. Callers may override via ExtractOptions. +const ( + DefaultMaxEntries = 10_000 + DefaultMaxTotalUncompressed = 512 * 1024 * 1024 +) + +// ExtractOptions configures SafeExtract behavior. +type ExtractOptions struct { + // OutputDir is the final destination directory. Created if missing. + OutputDir string + // Force allows overwriting existing files in OutputDir. When false, + // SafeExtract returns ErrCollision listing the first collision encountered + // and writes nothing. + Force bool + // MaxEntries caps the number of tar entries processed. Zero falls back to + // DefaultMaxEntries. + MaxEntries int + // MaxTotalUncompressed caps the total uncompressed byte count across all + // regular-file entries. Zero falls back to DefaultMaxTotalUncompressed. + MaxTotalUncompressed int64 +} + +// ExtractResult is returned on success. +type ExtractResult struct { + // Files is the list of regular files written, relative to OutputDir, + // in the order they were extracted. + Files []string + // TotalBytes is the cumulative uncompressed size of extracted files. + TotalBytes int64 +} + +// Sentinel errors. Each wraps additional context describing the offending +// entry / collision so callers can include it in the user-facing message. + +// ErrUnsafeEntry indicates a tar entry was rejected (absolute path, +// `..` component, symlink, hard link, non-regular file, or empty/`/`-only name). +var ErrUnsafeEntry = errors.New("unsafe tar entry") + +// ErrLimitExceeded indicates the entry count or uncompressed byte limit was +// exceeded mid-extraction. +var ErrLimitExceeded = errors.New("archive exceeds safety limit") + +// ErrCollision indicates the archive would overwrite a file that already +// exists in OutputDir and Force was not set. +var ErrCollision = errors.New("output collision") + +// ErrInvalidGzip is returned when the response is not a valid gzip stream. +var ErrInvalidGzip = errors.New("invalid gzip stream") + +// SafeExtract reads a gzipped tar stream from r and writes its regular-file +// contents under opts.OutputDir. The implementation is two-phase: +// +// 1. Stream the archive entries into a temporary staging directory under the +// OS temp dir, validating each entry against the safety rules before +// writing anything. +// 2. After every entry has been written to staging, copy each file into the +// final OutputDir. If anything fails partway, the staging directory is +// removed and nothing is left behind in OutputDir. +// +// Safety rules (each rejection returns ErrUnsafeEntry): +// +// - Absolute paths or paths containing `..` components are rejected. +// - Symbolic links and hard links are rejected. +// - Non-regular and non-directory entries (devices, FIFOs, sockets) are +// rejected. +// - Empty names, or names that collapse to "/" or "." after cleaning, are +// rejected. +// - Total entry count is capped at opts.MaxEntries (default 10,000). +// - Total uncompressed byte count is capped at opts.MaxTotalUncompressed +// (default 512 MB). +// +// Executable bits from tar headers are dropped; written files use 0600 / 0700 +// modes against the process umask. +func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { + if opts.OutputDir == "" { + return nil, fmt.Errorf("SafeExtract: OutputDir is required") + } + maxEntries := opts.MaxEntries + if maxEntries <= 0 { + maxEntries = DefaultMaxEntries + } + maxBytes := opts.MaxTotalUncompressed + if maxBytes <= 0 { + maxBytes = DefaultMaxTotalUncompressed + } + + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidGzip, err) + } + defer gz.Close() + + staging, err := os.MkdirTemp("", "azd-skill-extract-*") + if err != nil { + return nil, fmt.Errorf("create staging directory: %w", err) + } + cleanupStaging := func() { + _ = os.RemoveAll(staging) + } + + tr := tar.NewReader(gz) + var files []string + var totalBytes int64 + entryCount := 0 + + for { + hdr, hdrErr := tr.Next() + if errors.Is(hdrErr, io.EOF) { + break + } + if hdrErr != nil { + cleanupStaging() + return nil, fmt.Errorf("read tar entry: %w", hdrErr) + } + + entryCount++ + if entryCount > maxEntries { + cleanupStaging() + return nil, fmt.Errorf("%w: entry count exceeds %d", ErrLimitExceeded, maxEntries) + } + + cleaned, ok := validateEntryName(hdr.Name) + if !ok { + cleanupStaging() + return nil, fmt.Errorf("%w: %q", ErrUnsafeEntry, hdr.Name) + } + + switch hdr.Typeflag { + case tar.TypeReg, tar.TypeRegA: + // Fall through. + case tar.TypeDir: + dirPath := filepath.Join(staging, filepath.FromSlash(cleaned)) + if mkErr := os.MkdirAll(dirPath, 0700); mkErr != nil { + cleanupStaging() + return nil, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) + } + continue + default: + cleanupStaging() + return nil, fmt.Errorf("%w: %q has unsupported tar type %c", ErrUnsafeEntry, hdr.Name, hdr.Typeflag) + } + + // Reject explicit zero-size entries that the spec only allows for + // directories. Regular files with hdr.Size == 0 are fine (empty files). + + // Pre-create parent directory in staging. + stagingPath := filepath.Join(staging, filepath.FromSlash(cleaned)) + if mkErr := os.MkdirAll(filepath.Dir(stagingPath), 0700); mkErr != nil { + cleanupStaging() + return nil, fmt.Errorf("create staging dir for %q: %w", cleaned, mkErr) + } + + remaining := maxBytes - totalBytes + if hdr.Size > 0 && hdr.Size > remaining { + cleanupStaging() + return nil, fmt.Errorf("%w: uncompressed size would exceed %d bytes", ErrLimitExceeded, maxBytes) + } + + // Cap reading at the remaining budget so a lying header cannot run away. + limited := io.LimitReader(tr, remaining+1) + + f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // stagingPath is inside our trusted staging dir + if fErr != nil { + cleanupStaging() + return nil, fmt.Errorf("create staging file %q: %w", cleaned, fErr) + } + + written, copyErr := io.Copy(f, limited) + closeErr := f.Close() + if copyErr != nil { + cleanupStaging() + return nil, fmt.Errorf("write %q: %w", cleaned, copyErr) + } + if closeErr != nil { + cleanupStaging() + return nil, fmt.Errorf("close staging file %q: %w", cleaned, closeErr) + } + if written > remaining { + cleanupStaging() + return nil, fmt.Errorf("%w: uncompressed size would exceed %d bytes", ErrLimitExceeded, maxBytes) + } + totalBytes += written + files = append(files, cleaned) + } + + // All entries validated and written to staging. Check for collisions in + // OutputDir before any final copy so a partial copy never leaves files behind. + if mkErr := os.MkdirAll(opts.OutputDir, 0700); mkErr != nil { + cleanupStaging() + return nil, fmt.Errorf("create output dir: %w", mkErr) + } + + if !opts.Force { + for _, rel := range files { + dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) + if _, statErr := os.Lstat(dst); statErr == nil { + cleanupStaging() + return nil, fmt.Errorf("%w: %s already exists in %s", ErrCollision, rel, opts.OutputDir) + } else if !errors.Is(statErr, os.ErrNotExist) { + cleanupStaging() + return nil, fmt.Errorf("stat %q: %w", dst, statErr) + } + } + } + + for _, rel := range files { + src := filepath.Join(staging, filepath.FromSlash(rel)) + dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) + if mkErr := os.MkdirAll(filepath.Dir(dst), 0700); mkErr != nil { + cleanupStaging() + return nil, fmt.Errorf("create output dir for %q: %w", rel, mkErr) + } + if err := copyFile(src, dst); err != nil { + cleanupStaging() + return nil, fmt.Errorf("copy %q to output: %w", rel, err) + } + } + + cleanupStaging() + return &ExtractResult{ + Files: files, + TotalBytes: totalBytes, + }, nil +} + +// validateEntryName cleans and validates a tar entry name. It returns the +// cleaned, slash-rooted relative path on success (no leading slash, no `..` +// segments). +// +// `..` is rejected even when surrounding segments cancel it out (e.g. +// `a/../b`). Cleaning would collapse that to `b`, which is technically safe +// from zip-slip, but the design spec rejects any `..` segment defensively +// so a future bug in `path.Clean` cannot regress us. +func validateEntryName(name string) (string, bool) { + if name == "" { + return "", false + } + // Tar entry names use forward slashes. Normalize to slashes before cleaning + // so we behave the same on Windows and Unix. + slashed := strings.ReplaceAll(name, "\\", "/") + // Reject Windows drive-letter syntax masquerading as a relative path. + if len(slashed) >= 2 && slashed[1] == ':' { + return "", false + } + // Reject absolute paths. + if strings.HasPrefix(slashed, "/") { + return "", false + } + // Reject any `..` segment in the *raw* name. This is stricter than + // path.Clean — we want to refuse archives that even attempt traversal. + for _, part := range strings.Split(slashed, "/") { + if part == ".." { + return "", false + } + } + // path.Clean to remove redundant separators and resolve `.` segments + // without traversing the filesystem. + cleaned := path.Clean(slashed) + if cleaned == "" || cleaned == "." || cleaned == "/" { + return "", false + } + if path.IsAbs(cleaned) || strings.HasPrefix(cleaned, "/") { + return "", false + } + return cleaned, true +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // src is inside our trusted staging dir + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // dst is inside the user-supplied output dir, written on user behalf + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go new file mode 100644 index 00000000000..4d9dab66cdc --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// makeTarGz builds a gzip-compressed tar archive in memory containing the +// provided entries. Each entry's TypeFlag drives the tar header type. +func makeTarGz(t *testing.T, entries []tar.Header, bodies map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, h := range entries { + // Set Size from body when present. + if h.Typeflag == tar.TypeReg || h.Typeflag == tar.TypeRegA { + h.Size = int64(len(bodies[h.Name])) + } + require.NoError(t, tw.WriteHeader(&h)) + if body, ok := bodies[h.Name]; ok { + _, err := tw.Write(body) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +func TestSafeExtract_HappyPath(t *testing.T) { + bodies := map[string][]byte{ + "SKILL.md": []byte("---\nname: foo\n---\nbody\n"), + "assets/icon.svg": []byte(""), + "assets/notes.txt": []byte("hello"), + } + entries := []tar.Header{ + {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, + {Name: "assets/", Mode: 0755, Typeflag: tar.TypeDir}, + {Name: "assets/icon.svg", Mode: 0644, Typeflag: tar.TypeReg}, + {Name: "assets/notes.txt", Mode: 0644, Typeflag: tar.TypeReg}, + } + archive := makeTarGz(t, entries, bodies) + dir := t.TempDir() + + res, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: dir}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"SKILL.md", "assets/icon.svg", "assets/notes.txt"}, res.Files) + + got, _ := os.ReadFile(filepath.Join(dir, "assets", "icon.svg")) //nolint:gosec // test artifact + require.Equal(t, "", string(got)) +} + +func TestSafeExtract_RejectsDotDot(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: "../evil.txt", Mode: 0644, Typeflag: tar.TypeReg}}, + map[string][]byte{"../evil.txt": []byte("nope")}, + ) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsAbsolutePath(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: "/etc/passwd", Mode: 0644, Typeflag: tar.TypeReg}}, + map[string][]byte{"/etc/passwd": []byte("nope")}, + ) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsSymlink(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: "link", Mode: 0777, Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}}, + nil, + ) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsHardLink(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: "link", Mode: 0644, Linkname: "target", Typeflag: tar.TypeLink}}, + nil, + ) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsWindowsBackslash(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: `..\evil.txt`, Mode: 0644, Typeflag: tar.TypeReg}}, + map[string][]byte{`..\evil.txt`: []byte("nope")}, + ) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_EnforcesEntryCount(t *testing.T) { + headers := make([]tar.Header, 5) + bodies := map[string][]byte{} + for i := range headers { + name := filepath.ToSlash(filepath.Join("entry", string(rune('a'+i))+".txt")) + headers[i] = tar.Header{Name: name, Mode: 0644, Typeflag: tar.TypeReg} + bodies[name] = []byte{} + } + archive := makeTarGz(t, headers, bodies) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{ + OutputDir: t.TempDir(), + MaxEntries: 2, + }) + require.Error(t, err) + require.True(t, errors.Is(err, ErrLimitExceeded)) +} + +func TestSafeExtract_EnforcesTotalSize(t *testing.T) { + body := bytes.Repeat([]byte("a"), 1024) + archive := makeTarGz(t, + []tar.Header{{Name: "big.txt", Mode: 0644, Typeflag: tar.TypeReg}}, + map[string][]byte{"big.txt": body}, + ) + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{ + OutputDir: t.TempDir(), + MaxTotalUncompressed: 100, + }) + require.Error(t, err) + require.True(t, errors.Is(err, ErrLimitExceeded)) +} + +func TestSafeExtract_RejectsCollisionWithoutForce(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}}, + map[string][]byte{"SKILL.md": []byte("body")}, + ) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("old"), 0600)) + + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: dir}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrCollision)) + + // Existing file is untouched. + existing, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")) //nolint:gosec // test artifact + require.Equal(t, "old", string(existing)) +} + +func TestSafeExtract_ForceOverwrites(t *testing.T) { + archive := makeTarGz(t, + []tar.Header{{Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}}, + map[string][]byte{"SKILL.md": []byte("new")}, + ) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("old"), 0600)) + + _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: dir, Force: true}) + require.NoError(t, err) + + got, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")) //nolint:gosec // test artifact + require.Equal(t, "new", string(got)) +} + +func TestSafeExtract_InvalidGzip(t *testing.T) { + _, err := SafeExtract(bytes.NewReader([]byte("not a gzip stream")), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrInvalidGzip)) +} + +func TestValidateEntryName(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"SKILL.md", "SKILL.md", true}, + {"assets/icon.svg", "assets/icon.svg", true}, + {"./SKILL.md", "SKILL.md", true}, + {"", "", false}, + {".", "", false}, + {"/", "", false}, + {"/etc/passwd", "", false}, + {"../evil", "", false}, + {"a/../b", "", false}, + {`C:\Windows\Temp`, "", false}, + } + for _, c := range cases { + got, ok := validateEntryName(c.in) + require.Equal(t, c.wantOK, ok, "input=%q", c.in) + require.Equal(t, c.want, got, "input=%q", c.in) + } +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go new file mode 100644 index 00000000000..a2db29830bf --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +const ( + // DataPlaneAPIVersion is the api-version query parameter applied to every + // request. Pinned to the preview that exposes the Skills surface; bump in + // lockstep with the rest of the AI extension surface. + DataPlaneAPIVersion = "2025-11-15-preview" + + // FoundryFeaturesHeader is the HTTP header that carries the preview opt-in. + FoundryFeaturesHeader = "Foundry-Features" + // SkillsPreviewOptIn is the required Foundry-Features value for all skill + // operations in this preview. The TypeSpec marks every skill route with + // WithRequiredFoundryPreviewHeader. + SkillsPreviewOptIn = "Skills=V1Preview" + + // ContentTypeJSON is the request/response content type used for the JSON + // surface. + ContentTypeJSON = "application/json" + // ContentTypeGzip is the content type used by POST /skills:import (request) + // and by GET /skills/{name}:download (response). + ContentTypeGzip = "application/gzip" + + // BearerScope is the Azure AD scope used for the bearer-token policy. + // Matches the scope used by the rest of the Foundry AI extension surface. + //nolint:gosec // OAuth scope identifier, not a credential + BearerScope = "https://ai.azure.com/.default" + + // userAgentPrefix is the User-Agent value baseline; callers append their + // own version via the userAgent parameter to NewClient. + userAgentPrefix = "azd-ext-azure-ai-skills" +) + +// Client is the typed REST client for the Foundry Skills data-plane surface. +// All methods include the required preview header and api-version query +// parameter automatically. +type Client struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewClient returns a Skills client rooted at endpoint (already validated by +// the caller), using cred for bearer-token auth. extensionVersion is appended +// to the User-Agent value emitted by the pipeline. +func NewClient(endpoint string, cred azcore.TokenCredential, extensionVersion string) *Client { + return newClient(endpoint, cred, extensionVersion, false) +} + +func newClient(endpoint string, cred azcore.TokenCredential, extensionVersion string, allowHTTP bool) *Client { + userAgent := userAgentPrefix + if extensionVersion != "" { + userAgent += "/" + extensionVersion + } + + clientOptions := &policy.ClientOptions{ + // IncludeBody is intentionally false: skill create/update bodies carry + // user-authored description and instructions. Body logging will be + // enabled in a follow-up once a sanitizer is in place. + Logging: policy.LogOptions{IncludeBody: false}, + InsecureAllowCredentialWithHTTP: allowHTTP, + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy(cred, []string{BearerScope}, &policy.BearerTokenOptions{ + InsecureAllowCredentialWithHTTP: allowHTTP, + }), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-skills", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &Client{ + endpoint: strings.TrimRight(endpoint, "/"), + pipeline: pipeline, + } +} + +// CreateInline creates a skill from a JSON body (inline or parsed SKILL.md). +func (c *Client) CreateInline(ctx context.Context, req CreateRequest) (*Skill, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal create request: %w", err) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.buildURL("/skills", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := setJSONBody(httpReq, body); err != nil { + return nil, err + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + return decodeSkill(resp.Body) +} + +// CreatePackage uploads a gzip archive to POST /skills:import. The CLI does +// not inspect the contents; server-side validation owns the archive. +// nameHint is currently unused because the service derives the skill name +// from the archive's SKILL.md, but it is forwarded as a query parameter when +// non-empty so future server-side enforcement can match it against the URL. +func (c *Client) CreatePackage(ctx context.Context, archive io.ReadSeeker, archiveSize int64) (*Skill, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.buildURL("/skills:import", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := httpReq.SetBody(streaming(archive), ContentTypeGzip); err != nil { + return nil, fmt.Errorf("set request body: %w", err) + } + httpReq.Raw().ContentLength = archiveSize + httpReq.Raw().Header.Set("Content-Type", ContentTypeGzip) + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + return decodeSkill(resp.Body) +} + +// Get returns the metadata for a skill. +func (c *Client) Get(ctx context.Context, name string) (*Skill, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + return decodeSkill(resp.Body) +} + +// Update merges req into the existing skill via POST /skills/{name}. +// The caller is responsible for the GET-merge-POST pattern; this method +// simply sends the supplied body. +func (c *Client) Update(ctx context.Context, name string, req UpdateRequest) (*Skill, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal update request: %w", err) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.skillURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := setJSONBody(httpReq, body); err != nil { + return nil, err + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + return decodeSkill(resp.Body) +} + +// Delete removes a skill. The service returns a small JSON body describing +// the deletion which the caller may use. +func (c *Client) Delete(ctx context.Context, name string) (*DeleteResponse, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodDelete, c.skillURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return nil, runtime.NewResponseError(resp) + } + + if resp.StatusCode == http.StatusNoContent { + return &DeleteResponse{Name: name, Deleted: true}, nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if len(bytes.TrimSpace(raw)) == 0 { + return &DeleteResponse{Name: name, Deleted: true}, nil + } + + var dr DeleteResponse + if err := json.Unmarshal(raw, &dr); err != nil { + return nil, fmt.Errorf("unmarshal delete response: %w", err) + } + if dr.Name == "" { + dr.Name = name + } + return &dr, nil +} + +// List fetches one page of skills using the supplied options. The returned +// PagedSkills includes pagination cursors the caller can use to fetch more. +func (c *Client) List(ctx context.Context, opts ListOptions, afterCursor string) (*PagedSkills, error) { + q := url.Values{} + if opts.Top > 0 { + q.Set("limit", strconv.Itoa(opts.Top)) + } + if opts.OrderBy != "" { + q.Set("order", opts.OrderBy) + } + if afterCursor != "" { + q.Set("after", afterCursor) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.buildURL("/skills", q)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var wire pagedSkillsWire + if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil { + return nil, fmt.Errorf("decode list response: %w", err) + } + paged := wire.toPagedSkills() + return &paged, nil +} + +// ListAll fetches every page transparently and returns the flattened slice. +// If limit is positive, ListAll stops as soon as that many items are collected. +func (c *Client) ListAll(ctx context.Context, opts ListOptions, limit int) ([]Skill, error) { + var all []Skill + cursor := "" + for { + page, err := c.List(ctx, opts, cursor) + if err != nil { + return nil, err + } + all = append(all, page.Data...) + if limit > 0 && len(all) >= limit { + return all[:limit], nil + } + if !page.HasMore || page.LastID == "" { + return all, nil + } + cursor = page.LastID + } +} + +// Download streams the gzip archive for a skill. The returned ReadCloser +// surfaces the raw bytes returned by the service; the caller is responsible +// for closing it. The Content-Type header is validated to start with +// `application/gzip`. +func (c *Client) Download(ctx context.Context, name string) (io.ReadCloser, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, ":download", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + // Accept: */* (gzip). We do not want runtime to ask for JSON. + httpReq.Raw().Header.Set("Accept", ContentTypeGzip) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + + if !runtime.HasStatusCode(resp, http.StatusOK) { + defer resp.Body.Close() + return nil, runtime.NewResponseError(resp) + } + + if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(strings.ToLower(ct), ContentTypeGzip) { + defer resp.Body.Close() + return nil, fmt.Errorf("unexpected download content type %q (want %s)", ct, ContentTypeGzip) + } + + return resp.Body, nil +} + +// --- URL and header helpers --- + +func (c *Client) buildURL(path string, extraQuery url.Values) string { + q := url.Values{} + q.Set("api-version", DataPlaneAPIVersion) + for k, vs := range extraQuery { + for _, v := range vs { + q.Add(k, v) + } + } + return c.endpoint + path + "?" + q.Encode() +} + +// skillURL builds a per-skill URL with optional action suffix +// (e.g. ":download"). The skill name is URL-path-escaped to handle service- +// accepted characters safely; CLI-side validation rejects most of these. +func (c *Client) skillURL(name, suffix string, extraQuery url.Values) string { + path := "/skills/" + url.PathEscape(name) + suffix + return c.buildURL(path, extraQuery) +} + +func setJSONBody(req *policy.Request, body []byte) error { + if err := req.SetBody(streaming(bytes.NewReader(body)), ContentTypeJSON); err != nil { + return fmt.Errorf("set request body: %w", err) + } + return nil +} + +func addStandardHeaders(req *policy.Request) { + h := req.Raw().Header + h.Set(FoundryFeaturesHeader, SkillsPreviewOptIn) + if h.Get("Accept") == "" { + h.Set("Accept", ContentTypeJSON) + } +} + +func decodeSkill(body io.Reader) (*Skill, error) { + var wire skillWire + if err := json.NewDecoder(body).Decode(&wire); err != nil { + return nil, fmt.Errorf("decode skill response: %w", err) + } + s := wire.toSkill() + return &s, nil +} + +// streaming wraps an io.ReadSeeker into the io.ReadSeekCloser required by +// runtime.NewRequest's SetBody. We never close the underlying reader; the +// caller owns its lifecycle (the SDK reads then ignores Close on this shim). +type readSeekNopCloser struct{ io.ReadSeeker } + +func (readSeekNopCloser) Close() error { return nil } + +func streaming(rs io.ReadSeeker) io.ReadSeekCloser { + return readSeekNopCloser{rs} +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go new file mode 100644 index 00000000000..11fd6123099 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/stretchr/testify/require" +) + +// fakeCredential is a no-op token credential for tests. It returns a static +// token without contacting any service. +type fakeCredential struct{} + +func (fakeCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "test-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +// newTestClient builds a Skills client rooted at the given httptest server URL. +// Uses the unexported insecure-http constructor so the bearer policy doesn't +// reject the plain-HTTP httptest endpoint. +func newTestClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + return newClient(srv.URL, fakeCredential{}, "test", true) +} + +func TestClient_CreateInline_SendsRequiredHeadersAndQuery(t *testing.T) { + var capturedAPI string + var capturedFeatures string + var capturedContentType string + var capturedBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAPI = r.URL.Query().Get("api-version") + capturedFeatures = r.Header.Get(FoundryFeaturesHeader) + capturedContentType = r.Header.Get("Content-Type") + require.Equal(t, "/skills", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &capturedBody)) + + resp := skillWire{SkillID: "sk-1", Name: "my-skill", Description: "from-server"} + w.Header().Set("Content-Type", ContentTypeJSON) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c := newTestClient(t, srv) + skill, err := c.CreateInline(context.Background(), CreateRequest{ + Name: "my-skill", + Description: "client-desc", + Instructions: "client-body", + }) + require.NoError(t, err) + require.Equal(t, DataPlaneAPIVersion, capturedAPI) + require.Equal(t, SkillsPreviewOptIn, capturedFeatures) + require.Equal(t, ContentTypeJSON, capturedContentType) + require.Equal(t, "my-skill", capturedBody["name"]) + require.Equal(t, "client-desc", capturedBody["description"]) + require.Equal(t, "client-body", capturedBody["instructions"]) + require.Equal(t, "sk-1", skill.SkillID) + require.Equal(t, "from-server", skill.Description) +} + +func TestClient_CreatePackage_SendsGzipContentType(t *testing.T) { + var capturedCT string + var capturedPath string + var capturedBytes []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedCT = r.Header.Get("Content-Type") + capturedBytes, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", ContentTypeJSON) + _ = json.NewEncoder(w).Encode(skillWire{Name: "my-skill", HasBlob: true}) + })) + defer srv.Close() + + payload := []byte("\x1f\x8b\x08\x00fake-gzip") + c := newTestClient(t, srv) + skill, err := c.CreatePackage(context.Background(), strings.NewReader(string(payload)), int64(len(payload))) + require.NoError(t, err) + require.Equal(t, "/skills:import", capturedPath) + require.Equal(t, ContentTypeGzip, capturedCT) + require.Equal(t, payload, capturedBytes) + require.True(t, skill.HasBlob) +} + +func TestClient_Get_DecodesSnakeCase(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill", r.URL.Path) + require.Equal(t, SkillsPreviewOptIn, r.Header.Get(FoundryFeaturesHeader)) + _, _ = io.WriteString(w, `{"skill_id":"sk-1","name":"my-skill","has_blob":true,"description":"d"}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Get(context.Background(), "my-skill") + require.NoError(t, err) + require.Equal(t, "sk-1", got.SkillID) + require.True(t, got.HasBlob) + require.Equal(t, "d", got.Description) +} + +func TestClient_List_FlattensPagination(t *testing.T) { + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills", r.URL.Path) + switch page { + case 0: + // First page: 2 items, has_more=true, last_id=b. + require.Empty(t, r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `{"data":[{"name":"a"},{"name":"b"}],"has_more":true,"last_id":"b"}`) + case 1: + // Second page: cursor honored, 1 item, has_more=false. + require.Equal(t, "b", r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `{"data":[{"name":"c"}],"has_more":false}`) + default: + t.Fatalf("unexpected extra page request: %d", page) + } + page++ + })) + defer srv.Close() + + c := newTestClient(t, srv) + all, err := c.ListAll(context.Background(), ListOptions{Top: 0}, 0) + require.NoError(t, err) + require.Equal(t, []string{"a", "b", "c"}, []string{all[0].Name, all[1].Name, all[2].Name}) +} + +func TestClient_List_HonorsLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "5", r.URL.Query().Get("limit")) + require.Equal(t, "desc", r.URL.Query().Get("order")) + _, _ = io.WriteString(w, `{"data":[{"name":"a"},{"name":"b"},{"name":"c"}],"has_more":true,"last_id":"c"}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + all, err := c.ListAll(context.Background(), ListOptions{Top: 5, OrderBy: "desc"}, 2) + require.NoError(t, err) + require.Len(t, all, 2) +} + +func TestClient_Download_ValidatesContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill:download", r.URL.Path) + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "not gzip") + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.Download(context.Background(), "my-skill") + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected download content type") +} + +func TestClient_Download_PassesThroughGzipStream(t *testing.T) { + payload := []byte("fake-gzip-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", ContentTypeGzip) + _, _ = w.Write(payload) + })) + defer srv.Close() + + c := newTestClient(t, srv) + rc, err := c.Download(context.Background(), "my-skill") + require.NoError(t, err) + got, _ := io.ReadAll(rc) + _ = rc.Close() + require.Equal(t, payload, got) +} + +func TestClient_Delete_ReturnsServiceResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/skills/my-skill", r.URL.Path) + _, _ = io.WriteString(w, `{"name":"my-skill","deleted":true}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + resp, err := c.Delete(context.Background(), "my-skill") + require.NoError(t, err) + require.True(t, resp.Deleted) + require.Equal(t, "my-skill", resp.Name) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go new file mode 100644 index 00000000000..0b1db5b28f2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package skill_api provides a typed REST client for the Foundry Skills +// data-plane surface, plus helpers for parsing SKILL.md files and safely +// extracting downloaded gzip skill packages. +package skill_api + +// Skill is the metadata representation of a Foundry skill returned by +// the Skills data-plane surface. Fields are camelCase in JSON to match the +// published JSON contract for the CLI; the wire format from the service is +// snake_case and is translated by the wire-to-public conversions below. +type Skill struct { + // SkillID is the unique service-assigned identifier. + SkillID string `json:"skillId,omitempty"` + // Name is the unique skill name (validated client-side against the + // alphanumeric-with-hyphens pattern; final decision lives in the service). + Name string `json:"name"` + // HasBlob reports whether the skill was created from a gzip package. + // Determines whether `download` returns useful content. + HasBlob bool `json:"hasBlob"` + // Description is a human-readable summary; optional. + Description string `json:"description,omitempty"` + // Metadata is the freeform string-to-string map the service stores + // alongside the skill. May be nil. + Metadata map[string]string `json:"metadata,omitempty"` +} + +// skillWire mirrors Skill but uses the snake_case wire field names that the +// Foundry Skills surface returns. Decoding into Skill goes through this struct +// so the public JSON contract for the CLI stays camelCase regardless of how +// the service evolves. +type skillWire struct { + SkillID string `json:"skill_id,omitempty"` + Name string `json:"name"` + HasBlob bool `json:"has_blob,omitempty"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +func (w skillWire) toSkill() Skill { + return Skill{ + SkillID: w.SkillID, + Name: w.Name, + HasBlob: w.HasBlob, + Description: w.Description, + Metadata: w.Metadata, + } +} + +// CreateRequest is the inline JSON body for `POST /skills`. Either both of +// Description and Instructions are set (inline mode) or they come from a +// parsed SKILL.md file. The CLI populates Name from the positional argument +// and never trusts the front-matter value. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// UpdateRequest is the merged JSON body for `POST /skills/{name}`. Only +// non-empty fields are sent; the action layer performs the GET-merge-POST. +type UpdateRequest struct { + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// DeleteResponse is the JSON body returned by `DELETE /skills/{name}`. +type DeleteResponse struct { + Name string `json:"name"` + Deleted bool `json:"deleted"` +} + +// PagedSkills is one page of the `GET /skills` response. +type PagedSkills struct { + Data []Skill `json:"data"` + FirstID string `json:"firstId,omitempty"` + LastID string `json:"lastId,omitempty"` + HasMore bool `json:"hasMore"` +} + +// pagedSkillsWire is the snake_case wire form of PagedSkills. +type pagedSkillsWire struct { + Data []skillWire `json:"data"` + FirstID string `json:"first_id,omitempty"` + LastID string `json:"last_id,omitempty"` + HasMore bool `json:"has_more"` +} + +func (w pagedSkillsWire) toPagedSkills() PagedSkills { + out := PagedSkills{ + FirstID: w.FirstID, + LastID: w.LastID, + HasMore: w.HasMore, + } + if len(w.Data) > 0 { + out.Data = make([]Skill, 0, len(w.Data)) + for _, item := range w.Data { + out.Data = append(out.Data, item.toSkill()) + } + } + return out +} + +// ListOptions configures a `GET /skills` request. Zero values mean "let the +// service apply its defaults". +type ListOptions struct { + // Top is the per-page item limit. The service caps this at 100. + Top int + // OrderBy is forwarded to the `order` query parameter + // (typically `asc` or `desc`). + OrderBy string +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go new file mode 100644 index 00000000000..7be31f2a3ff --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bufio" + "bytes" + "fmt" + "strings" + + "go.yaml.in/yaml/v3" +) + +// SkillMd represents the parsed contents of a SKILL.md file. +// +// SKILL.md files are Markdown files with a YAML front matter block delimited +// by three-dash lines. The CLI extracts the structured fields it knows about +// (Name, Description, Metadata) and uses the remaining Markdown body as the +// skill's `instructions`. Any unrecognized front matter keys are preserved +// verbatim in RawFrontMatter so callers can forward them to the service. +type SkillMd struct { + // Name is the optional `name` value from the YAML front matter. If the + // positional command argument differs, the positional value wins and + // the caller prints a one-line warning to stderr. + Name string + // Description is the optional human-readable summary. + Description string + // Metadata is the optional string-to-string map from front matter. + Metadata map[string]string + // Instructions is the Markdown body that follows the closing `---`. Leading + // whitespace and a single trailing newline are preserved verbatim. + Instructions string + // RawFrontMatter contains every parsed front-matter key (including those + // already exposed as named fields). Useful for forwarding unknown + // service-recognized fields without losing information. + RawFrontMatter map[string]any +} + +// ParseSkillMd reads a SKILL.md document from data and returns its components. +// +// The document must start with a YAML front matter block delimited by lines +// containing only `---`. Both the opening and closing delimiters are +// required. Empty input, missing/unparsable front matter, and YAML errors +// all return a non-nil error that callers should wrap in a structured +// validation error. +func ParseSkillMd(data []byte) (*SkillMd, error) { + if len(bytes.TrimSpace(data)) == 0 { + return nil, fmt.Errorf("SKILL.md is empty") + } + + openIdx, closeIdx, err := findFrontMatterBounds(data) + if err != nil { + return nil, err + } + + fmBytes := data[openIdx:closeIdx] + bodyStart := closeIdx + len(frontMatterDelimiter) + // Skip a single newline after the closing delimiter so the body does not + // start with a leading blank line that the user did not write. + if bodyStart < len(data) { + if data[bodyStart] == '\r' && bodyStart+1 < len(data) && data[bodyStart+1] == '\n' { + bodyStart += 2 + } else if data[bodyStart] == '\n' { + bodyStart++ + } + } + + var raw map[string]any + if err := yaml.Unmarshal(fmBytes, &raw); err != nil { + return nil, fmt.Errorf("parse SKILL.md front matter: %w", err) + } + if raw == nil { + // `---\n---` with no body is technically valid YAML (null document) + // but useless for skill creation. Treat as missing. + return nil, fmt.Errorf("SKILL.md front matter is empty") + } + + out := &SkillMd{ + RawFrontMatter: raw, + Instructions: string(data[bodyStart:]), + } + + if v, ok := raw["name"]; ok { + s, sErr := frontMatterString("name", v) + if sErr != nil { + return nil, sErr + } + out.Name = s + } + if v, ok := raw["description"]; ok { + s, sErr := frontMatterString("description", v) + if sErr != nil { + return nil, sErr + } + out.Description = s + } + if v, ok := raw["metadata"]; ok { + m, mErr := frontMatterStringMap("metadata", v) + if mErr != nil { + return nil, mErr + } + out.Metadata = m + } + + return out, nil +} + +const frontMatterDelimiter = "---" + +// findFrontMatterBounds locates the opening `---` and closing `---` markers +// for the YAML front matter block. The returned indices bracket the YAML body +// (exclusive of the delimiter lines themselves). +// +// The opening delimiter must be the first non-empty line of the file. Any +// leading whitespace lines are skipped to be tolerant of UTF-8 BOMs and +// editor-introduced blank lines. +func findFrontMatterBounds(data []byte) (open, close int, err error) { + scanner := bufio.NewScanner(bytes.NewReader(data)) + // Allow up to 1 MiB per line so giant front matter blocks do not hit the + // default 64 KiB cap. Skills bodies are bounded by the server; front + // matter is small, but we set a roomy limit anyway. + scanner.Buffer(make([]byte, 0, 4096), 1<<20) + + sawOpen := false + lineOffset := 0 + // We need the byte offset of each line's start, including its newline + // terminator. bufio.Scanner doesn't expose offsets directly, so we + // recompute them by tracking how many bytes we've advanced. + cur := data + lineNum := 0 + for { + nl := bytes.IndexByte(cur, '\n') + var line []byte + var step int + if nl < 0 { + line = cur + step = len(cur) + } else { + line = cur[:nl] + step = nl + 1 + } + trimmed := strings.TrimRight(string(line), "\r") + stripped := strings.TrimSpace(trimmed) + lineNum++ + + if !sawOpen { + if stripped == "" { + lineOffset += step + cur = cur[step:] + if step == 0 { + return 0, 0, fmt.Errorf("SKILL.md must begin with a YAML front matter block delimited by '---'") + } + continue + } + if stripped != frontMatterDelimiter { + return 0, 0, fmt.Errorf("SKILL.md must begin with a YAML front matter block delimited by '---' (got %q on line %d)", trimmed, lineNum) + } + sawOpen = true + open = lineOffset + step + lineOffset += step + cur = cur[step:] + continue + } + + if stripped == frontMatterDelimiter { + close = lineOffset + return open, close, nil + } + + lineOffset += step + cur = cur[step:] + if step == 0 { + break + } + } + + return 0, 0, fmt.Errorf("SKILL.md front matter is missing its closing '---' delimiter") +} + +func frontMatterString(field string, v any) (string, error) { + switch typed := v.(type) { + case nil: + return "", nil + case string: + return typed, nil + default: + return "", fmt.Errorf("SKILL.md front matter field %q must be a string", field) + } +} + +func frontMatterStringMap(field string, v any) (map[string]string, error) { + if v == nil { + return nil, nil + } + raw, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("SKILL.md front matter field %q must be a mapping", field) + } + if len(raw) == 0 { + return nil, nil + } + out := make(map[string]string, len(raw)) + for k, val := range raw { + s, err := frontMatterString(field+"."+k, val) + if err != nil { + return nil, err + } + out[k] = s + } + return out, nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go new file mode 100644 index 00000000000..52523f37f8a --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseSkillMd_Valid(t *testing.T) { + doc := strings.Join([]string{ + "---", + "name: my-skill", + "description: Greets the user", + "metadata:", + " owner: alice", + "---", + "# Skill body", + "", + "Greet the user warmly.", + }, "\n") + + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "Greets the user", parsed.Description) + require.Equal(t, map[string]string{"owner": "alice"}, parsed.Metadata) + require.True(t, strings.HasPrefix(parsed.Instructions, "# Skill body")) +} + +func TestParseSkillMd_NoFrontMatter(t *testing.T) { + _, err := ParseSkillMd([]byte("Just some Markdown body without front matter.\n")) + require.Error(t, err) + require.Contains(t, err.Error(), "must begin with a YAML front matter block") +} + +func TestParseSkillMd_MissingCloseDelimiter(t *testing.T) { + doc := "---\nname: foo\n# body but no closing ---\n" + _, err := ParseSkillMd([]byte(doc)) + require.Error(t, err) + require.Contains(t, err.Error(), "missing its closing '---' delimiter") +} + +func TestParseSkillMd_Empty(t *testing.T) { + _, err := ParseSkillMd(nil) + require.Error(t, err) +} + +func TestParseSkillMd_InvalidYAML(t *testing.T) { + doc := "---\nname: [unterminated\n---\nbody\n" + _, err := ParseSkillMd([]byte(doc)) + require.Error(t, err) + require.Contains(t, err.Error(), "parse SKILL.md front matter") +} + +func TestParseSkillMd_NonStringField(t *testing.T) { + doc := "---\nname: 123\n---\nbody\n" + _, err := ParseSkillMd([]byte(doc)) + require.Error(t, err) + require.Contains(t, err.Error(), `field "name" must be a string`) +} + +func TestParseSkillMd_BodyOnly(t *testing.T) { + // Front matter present but empty body is allowed: instructions is "". + doc := "---\nname: my-skill\ndescription: Just metadata\n---\n" + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "Just metadata", parsed.Description) + require.Equal(t, "", parsed.Instructions) +} + +func TestParseSkillMd_LeadingBlankLines(t *testing.T) { + doc := "\n\n---\nname: my-skill\n---\nbody\n" + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) +} + +func TestParseSkillMd_CRLFLineEndings(t *testing.T) { + doc := "---\r\nname: my-skill\r\ndescription: works\r\n---\r\nbody\r\n" + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "works", parsed.Description) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/version/version.go b/cli/azd/extensions/azure.ai.skills/internal/version/version.go new file mode 100644 index 00000000000..52188c90118 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/version/version.go @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package version + +var ( + // Populated at build time + Version = "dev" // Default value for development builds + Commit = "none" + BuildDate = "unknown" +) diff --git a/cli/azd/extensions/azure.ai.skills/main.go b/cli/azd/extensions/azure.ai.skills/main.go new file mode 100644 index 00000000000..6e3a091c7d3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/main.go @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "azureaiskills/internal/cmd" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func main() { + azdext.Run(cmd.NewRootCommand()) +} diff --git a/cli/azd/extensions/azure.ai.skills/version.txt b/cli/azd/extensions/azure.ai.skills/version.txt new file mode 100644 index 00000000000..1d8239b9041 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/version.txt @@ -0,0 +1 @@ +0.0.1-preview From 4757f7257c0b747f216c04b5f46159df84cd2b08 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 15:16:41 +0800 Subject: [PATCH 02/13] fix(azure.ai.skills): endpoint scheme validation + symlink escape in SafeExtract P1 - Endpoint validation (Finding 1): - Add validateEndpoint() to endpoint.go that rejects non-https URLs and empty hosts before sending Azure bearer tokens to any resolved endpoint. - Called at all five resolution levels (flag, azd env, global config x2, host env var) so misconfigured endpoints fail with a clear message rather than an opaque SDK error. - Host-suffix validation intentionally deferred to HTTP layer per design. - Add TestResolveProjectEndpoint_InvalidScheme covering http/ftp/no-scheme/empty-host cases. P1 - Symlink escape in SafeExtract (Finding 2): - archive.go copy phase was vulnerable: if OutputDir contained a pre-existing symlink to a directory outside OutputDir, MkdirAll + copyFile would follow it silently, writing extracted files outside the intended destination. - Fix: resolve OutputDir with EvalSymlinks once; before each copyFile resolve the destination directory and assert it is under the real output dir. - Add isUnder() helper for path containment check. - Add TestSafeExtract_RejectsSymlinkParentEscape (skipped on Windows). --- .../azure.ai.skills/internal/cmd/endpoint.go | 39 ++++++- .../internal/cmd/endpoint_test.go | 20 ++++ .../internal/cmd/skill_create.go | 55 +++++++++ .../internal/cmd/skill_download.go | 37 +++++- .../internal/pkg/skill_api/archive.go | 47 +++++++- .../internal/pkg/skill_api/archive_peek.go | 109 ++++++++++++++++++ .../pkg/skill_api/archive_peek_test.go | 90 +++++++++++++++ .../internal/pkg/skill_api/archive_test.go | 40 +++++++ 8 files changed, 425 insertions(+), 12 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go index 2a18a6f75be..52a1b888d2a 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go @@ -5,8 +5,11 @@ package cmd import ( "context" + "fmt" "log" + "net/url" "os" + "strings" "azureaiskills/internal/exterrors" @@ -48,12 +51,14 @@ const ( // 4. Host env var FOUNDRY_PROJECT_ENDPOINT. // 5. Structured error. // -// The endpoint string is returned verbatim; URL validation is left to the -// caller (the existing agents extension validates against a Foundry-host -// suffix list, but the skills surface accepts any reachable HTTPS endpoint -// against the data plane, so we defer that check to the actual HTTP call). +// Each resolved value is validated to be an absolute https:// URL. +// Host-suffix validation is intentionally deferred to the HTTP layer — the +// skills data plane accepts any reachable HTTPS Foundry endpoint. func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, endpointSource, error) { if flagEndpoint != "" { + if err := validateEndpoint(flagEndpoint); err != nil { + return "", "", err + } return flagEndpoint, sourceFlag, nil } @@ -69,6 +74,9 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e EnvName: envResp.Environment.Name, Key: azdEnvKey, }); valErr == nil && valResp.Value != "" { + if err := validateEndpoint(valResp.Value); err != nil { + return "", "", err + } return valResp.Value, sourceAzdEnv, nil } } @@ -81,6 +89,9 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e if key == agentsContextEndpointKey { log.Printf("resolveProjectEndpoint: using fallback global config key %q", agentsContextEndpointKey) } + if err := validateEndpoint(endpoint); err != nil { + return "", "", err + } return endpoint, sourceGlobalConfig, nil } } @@ -89,6 +100,9 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e // 4. Host env var. if ep := os.Getenv(foundryEnvKey); ep != "" { + if err := validateEndpoint(ep); err != nil { + return "", "", err + } return ep, sourceFoundryEnv, nil } @@ -101,3 +115,20 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e "or export "+foundryEnvKey+" in your shell", ) } + +// validateEndpoint returns an error when the endpoint is not an absolute +// https:// URL. Host-suffix validation is intentionally deferred to the +// SDK layer; the skills surface accepts any reachable HTTPS Foundry endpoint. +func validateEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("invalid project endpoint %q: %w", endpoint, err) + } + if !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("invalid project endpoint %q: must use https scheme", endpoint) + } + if u.Host == "" { + return fmt.Errorf("invalid project endpoint %q: missing host", endpoint) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go index 72ea61778f7..54917ecdf80 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go @@ -26,6 +26,26 @@ func TestResolveProjectEndpoint_HostEnvVar(t *testing.T) { require.Equal(t, sourceFoundryEnv, src) } +func TestResolveProjectEndpoint_InvalidScheme(t *testing.T) { + cases := []struct { + name string + endpoint string + wantMsg string + }{ + {"http scheme", "http://example.com", "must use https scheme"}, + {"no scheme", "example.com/foo", "must use https scheme"}, + {"empty host", "https:///path", "missing host"}, + {"ftp scheme", "ftp://example.com", "must use https scheme"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := resolveProjectEndpoint(context.Background(), tc.endpoint) + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMsg) + }) + } +} + func TestResolveProjectEndpoint_MissingAll(t *testing.T) { t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go index 84ffd6adb99..d9f96be9a1f 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -72,6 +72,18 @@ func (a *createAction) Run(ctx context.Context) error { return err } + // In package mode, --force is destructive: it deletes the existing skill + // by positional name before we have any guarantee that the archive being + // uploaded targets the same skill. Peek into the archive's SKILL.md and + // verify the embedded `name` matches the positional argument *before* + // any destructive call. If the archive omits `name`, we accept it (the + // positional argument wins, matching inline/file-md behavior). + if mode == modeFilePackage && a.flags.force { + if err := verifyPackageNameMatches(a.flags.file, a.flags.name); err != nil { + return err + } + } + // Honor --force by deleting the existing skill first. if a.flags.force { if _, delErr := skillCtx.client.Delete(ctx, a.flags.name); delErr != nil { @@ -201,6 +213,49 @@ func printCreateResult(s *skill_api.Skill, format string) error { return printSkillDetail(s, outputTable) } +// verifyPackageNameMatches peeks SKILL.md from a .tar.gz archive and ensures +// its front-matter `name` (when present) matches the positional argument the +// user supplied. This guards `--force` in package mode against the +// destructive sequence "delete positional name, then upload archive that +// targets a different skill": without this check, a typo in the positional +// argument can wipe out an unrelated skill before the import is even +// attempted. +// +// Returns nil when the archive's SKILL.md is missing a `name` field (legacy +// archives), or when the embedded name matches positionalName. Returns a +// structured validation error otherwise. +func verifyPackageNameMatches(archivePath, positionalName string) error { + f, openErr := os.Open(archivePath) //nolint:gosec // user-supplied path opened on their behalf + if openErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot open %s: %s", archivePath, openErr), + "verify the file is readable", + ) + } + defer f.Close() + + archiveName, peekErr := skill_api.PeekArchiveSkillName(f) + if peekErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot inspect %s: %s", archivePath, peekErr), + "ensure the archive is a valid .tar.gz containing a SKILL.md file", + ) + } + if archiveName == "" || archiveName == positionalName { + return nil + } + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf( + "--force refused: archive declares name %q which does not match positional argument %q", + archiveName, positionalName, + ), + "re-run without --force, or fix the positional name / archive so they agree", + ) +} + // --- mode selection --- type createMode int diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go index f831026108e..312bd0cce8c 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -88,21 +88,46 @@ func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error { archivePath := filepath.Join(outputDir, a.flags.name+".tar.gz") - if !a.flags.force { - if _, statErr := os.Lstat(archivePath); statErr == nil { + // Always Lstat (even with --force) so we never follow a symlink and so we + // refuse to overwrite a non-regular file (directory, device, socket, ...). + if statInfo, statErr := os.Lstat(archivePath); statErr == nil { + if statInfo.Mode()&os.ModeSymlink != 0 { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s is a symlink; refusing to follow", archivePath), + "remove the symlink and re-run", + ) + } + if !statInfo.Mode().IsRegular() { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s exists and is not a regular file", archivePath), + "remove or rename the existing entry and re-run", + ) + } + if !a.flags.force { return exterrors.Validation( exterrors.CodeSkillOutputCollision, fmt.Sprintf("%s already exists", archivePath), "pass --force to overwrite", ) - } else if !errors.Is(statErr, os.ErrNotExist) { - return fmt.Errorf("stat %s: %w", archivePath, statErr) } + // --force: remove the existing regular file so the subsequent O_EXCL + // open creates a fresh file owned by this process. This avoids any + // TOCTOU window where the path could be swapped for a symlink between + // the Lstat and the open. + if rmErr := os.Remove(archivePath); rmErr != nil { + return fmt.Errorf("remove existing archive: %w", rmErr) + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", archivePath, statErr) } - // O_TRUNC ensures --force overwrites cleanly. + // O_EXCL guarantees we create the file ourselves; if anything appeared + // at archivePath in the meantime (e.g. a freshly-planted symlink), the + // open fails rather than silently following it. //nolint:gosec // archivePath is built from user-supplied --output-dir + skill name, written on user behalf - f, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + f, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) if err != nil { return fmt.Errorf("create archive: %w", err) } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index ec87e042d0c..25d04eb5e6a 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -221,13 +221,47 @@ func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { } } + // Resolve the real output directory path once so we can detect symlink + // escapes: if opts.OutputDir already contains a subdirectory that is a + // symlink pointing outside opts.OutputDir, an archive entry whose cleaned + // path starts with that component would silently write outside the + // intended destination. + realOutDir, evalErr := filepath.EvalSymlinks(opts.OutputDir) + if evalErr != nil { + cleanupStaging() + return nil, fmt.Errorf("resolve output dir path: %w", evalErr) + } + + // Preflight pass: create destination subdirectories and verify that every + // resolved destination path stays inside opts.OutputDir before any file + // data is copied. This preserves the documented contract that a partial + // failure leaves no files behind: if any entry would escape via a symlink, + // we abort here without having copied anything yet. for _, rel := range files { - src := filepath.Join(staging, filepath.FromSlash(rel)) dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) - if mkErr := os.MkdirAll(filepath.Dir(dst), 0700); mkErr != nil { + dstDir := filepath.Dir(dst) + if mkErr := os.MkdirAll(dstDir, 0700); mkErr != nil { cleanupStaging() return nil, fmt.Errorf("create output dir for %q: %w", rel, mkErr) } + realDstDir, evalErr := filepath.EvalSymlinks(dstDir) + if evalErr != nil { + cleanupStaging() + return nil, fmt.Errorf("resolve destination path for %q: %w", rel, evalErr) + } + if !isUnder(realDstDir, realOutDir) { + cleanupStaging() + return nil, fmt.Errorf( + "%w: %q destination escapes output directory via symlink", + ErrUnsafeEntry, rel, + ) + } + } + + // All destinations validated. Copy from staging to OutputDir. + for _, rel := range files { + src := filepath.Join(staging, filepath.FromSlash(rel)) + dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) if err := copyFile(src, dst); err != nil { cleanupStaging() return nil, fmt.Errorf("copy %q to output: %w", rel, err) @@ -283,6 +317,15 @@ func validateEntryName(name string) (string, bool) { return cleaned, true } +// isUnder reports whether child is the same as or nested within parent. +// Both paths must be cleaned real paths (output of filepath.EvalSymlinks). +func isUnder(child, parent string) bool { + if child == parent { + return true + } + return strings.HasPrefix(child, parent+string(filepath.Separator)) +} + func copyFile(src, dst string) error { in, err := os.Open(src) //nolint:gosec // src is inside our trusted staging dir if err != nil { diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go new file mode 100644 index 00000000000..19fdc5a2668 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "path" + "strings" +) + +// peekMaxSkillMdBytes caps how much SKILL.md data is read into memory by +// PeekArchiveSkillName. SKILL.md files are expected to be small; a 1 MB cap +// protects against an archive that names a huge file `SKILL.md` and tries to +// exhaust memory during the front-matter peek. +const peekMaxSkillMdBytes = 1 << 20 // 1 MiB + +// peekMaxEntries caps how many tar entries PeekArchiveSkillName scans before +// giving up. SKILL.md is conventionally at the archive root, so scanning +// every entry of a deeply-nested archive is unnecessary and would let a +// malicious archive stall the CLI. +const peekMaxEntries = 1024 + +// PeekArchiveSkillName reads the gzipped tar stream from r and returns the +// `name` field declared in the archive's SKILL.md front matter. +// +// Lookup rules (first match wins): +// +// - A file whose cleaned tar entry name equals `SKILL.md`. +// - A file whose cleaned tar entry name is `/SKILL.md`, +// i.e. a SKILL.md exactly one directory below the archive root. This +// matches the layout produced by `azd ai skill package`. +// +// Returns ("", nil) when no SKILL.md is found, when SKILL.md exists but does +// not declare a `name`, or when the front matter cannot be parsed. The +// caller is expected to treat an empty result as "no claim", not as an +// error: the destructive `--force` guard only fires when the archive makes +// a name claim that disagrees with the positional argument. +// +// PeekArchiveSkillName never writes to disk and reads at most peekMaxSkillMdBytes +// bytes from any single entry. It returns an error only for unrecoverable +// stream problems (invalid gzip, truncated tar header). +func PeekArchiveSkillName(r io.Reader) (string, error) { + gz, err := gzip.NewReader(r) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidGzip, err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + entryCount := 0 + for { + hdr, hdrErr := tr.Next() + if errors.Is(hdrErr, io.EOF) { + return "", nil + } + if hdrErr != nil { + return "", fmt.Errorf("read tar entry: %w", hdrErr) + } + entryCount++ + if entryCount > peekMaxEntries { + return "", nil + } + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + continue + } + if !isSkillMdEntry(hdr.Name) { + continue + } + // Limit how much we read so a malicious archive cannot make us + // allocate gigabytes for a SKILL.md "file". + data, readErr := io.ReadAll(io.LimitReader(tr, peekMaxSkillMdBytes)) + if readErr != nil { + return "", fmt.Errorf("read SKILL.md from archive: %w", readErr) + } + md, parseErr := ParseSkillMd(data) + if parseErr != nil { + // SKILL.md is present but malformed. The upload itself will fail + // validation; for the --force guard we treat this as "no claim" + // so we do not block on a parse error before the service has + // even seen the archive. + return "", nil + } + return md.Name, nil + } +} + +// isSkillMdEntry reports whether the given tar entry name refers to a +// SKILL.md file at the archive root or exactly one directory below it. +func isSkillMdEntry(name string) bool { + cleaned := path.Clean(strings.TrimLeft(name, "/")) + if cleaned == "SKILL.md" { + return true + } + // Match `/SKILL.md` (exactly one directory deep). + dir, file := path.Split(cleaned) + if file != "SKILL.md" { + return false + } + dir = strings.TrimSuffix(dir, "/") + if dir == "" || strings.Contains(dir, "/") { + return false + } + return true +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go new file mode 100644 index 00000000000..a7d8b53898f --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/tar" + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPeekArchiveSkillName_RootLevel(t *testing.T) { + bodies := map[string][]byte{ + "SKILL.md": []byte("---\nname: my-skill\ndescription: d\n---\nbody\n"), + } + entries := []tar.Header{ + {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, + } + got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + require.NoError(t, err) + require.Equal(t, "my-skill", got) +} + +func TestPeekArchiveSkillName_OneDirDeep(t *testing.T) { + bodies := map[string][]byte{ + "pkg/SKILL.md": []byte("---\nname: nested-skill\ndescription: d\n---\nbody\n"), + } + entries := []tar.Header{ + {Name: "pkg/", Mode: 0755, Typeflag: tar.TypeDir}, + {Name: "pkg/SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, + } + got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + require.NoError(t, err) + require.Equal(t, "nested-skill", got) +} + +func TestPeekArchiveSkillName_TooDeepIgnored(t *testing.T) { + bodies := map[string][]byte{ + "a/b/SKILL.md": []byte("---\nname: too-deep\ndescription: d\n---\nbody\n"), + } + entries := []tar.Header{ + {Name: "a/b/SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, + } + got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_NoSkillMd(t *testing.T) { + bodies := map[string][]byte{ + "readme.txt": []byte("hello"), + } + entries := []tar.Header{ + {Name: "readme.txt", Mode: 0644, Typeflag: tar.TypeReg}, + } + got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_MissingNameField(t *testing.T) { + bodies := map[string][]byte{ + "SKILL.md": []byte("---\ndescription: no name here\n---\nbody\n"), + } + entries := []tar.Header{ + {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, + } + got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_MalformedYAMLReturnsEmpty(t *testing.T) { + bodies := map[string][]byte{ + "SKILL.md": []byte("not a valid skill md without front matter"), + } + entries := []tar.Header{ + {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, + } + got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_InvalidGzip(t *testing.T) { + _, err := PeekArchiveSkillName(bytes.NewReader([]byte("this is not gzip"))) + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go index 4d9dab66cdc..2518b19222a 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -10,6 +10,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -203,3 +204,42 @@ func TestValidateEntryName(t *testing.T) { require.Equal(t, c.want, got, "input=%q", c.in) } } + +// TestSafeExtract_RejectsSymlinkParentEscape verifies that SafeExtract refuses +// to write a file when a path component of the destination is a pre-existing +// symlink that points outside opts.OutputDir. +func TestSafeExtract_RejectsSymlinkParentEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks on Windows requires elevated privileges") + } + + outDir := t.TempDir() + escapeTarget := t.TempDir() // outside outDir + + // Create a legitimate subdirectory name inside outDir that is actually a + // symlink pointing to escapeTarget. + symlinkPath := filepath.Join(outDir, "subdir") + require.NoError(t, os.Symlink(escapeTarget, symlinkPath)) + + // Build a tar.gz with an entry "subdir/secret.txt". After extraction the + // copy phase would try to write outDir/subdir/secret.txt, which resolves + // via the symlink to escapeTarget/secret.txt — outside outDir. + archive := makeTarGz(t, []tar.Header{ + {Name: "subdir/secret.txt", Typeflag: tar.TypeReg, Mode: 0600}, + }, map[string][]byte{ + "subdir/secret.txt": []byte("secret"), + }) + + opts := ExtractOptions{ + OutputDir: outDir, + MaxEntries: 10, + MaxTotalUncompressed: 1 << 20, + } + _, err := SafeExtract(bytes.NewReader(archive), opts) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry), "expected ErrUnsafeEntry, got %v", err) + + // Verify the file was NOT written to the symlink target. + _, statErr := os.Stat(filepath.Join(escapeTarget, "secret.txt")) + require.True(t, os.IsNotExist(statErr), "file must not be written through symlink to escape target") +} From 09a750b385a206f3bd2a0f867bb45d015ce6ea65 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 15:58:37 +0800 Subject: [PATCH 03/13] fix(skills): use api-version=v1 for skills surface The Skills data-plane lives under api-version=v1; the preview opt-in is communicated via the Foundry-Features: Skills=V1Preview header. Using 2025-11-15-preview as the api-version returns 400 UnsupportedApiVersion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.skills/internal/pkg/skill_api/client.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go index a2db29830bf..86b8f74930e 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -22,9 +22,10 @@ import ( const ( // DataPlaneAPIVersion is the api-version query parameter applied to every - // request. Pinned to the preview that exposes the Skills surface; bump in - // lockstep with the rest of the AI extension surface. - DataPlaneAPIVersion = "2025-11-15-preview" + // request. The Skills surface lives under the `v1` data-plane API version; + // the preview opt-in is communicated separately via the `Foundry-Features` + // header (see SkillsPreviewOptIn below). + DataPlaneAPIVersion = "v1" // FoundryFeaturesHeader is the HTTP header that carries the preview opt-in. FoundryFeaturesHeader = "Foundry-Features" From c95734864ae32fa16692cd112c0e3a4b31a2a5c8 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 16:06:21 +0800 Subject: [PATCH 04/13] fix(skills): pre-check has_blob before download for clearer error Skills created from inline JSON or SKILL.md have no downloadable package; the server returns an opaque 404 'does not have an associated package'. Pre-flight Get the skill so the download command can return a structured CodeSkillNoPackage validation error with an actionable suggestion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/skill_download.go | 20 +++++++++++++++++++ .../internal/exterrors/codes.go | 1 + 2 files changed, 21 insertions(+) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go index 312bd0cce8c..9583b6dc7d7 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -69,6 +69,26 @@ func (a *downloadAction) Run(ctx context.Context) error { return err } + // Pre-flight via Get so we can detect the "no associated package" case + // before issuing the :download call. The service returns 404 with a + // dedicated error code when the skill was created from inline JSON (or a + // SKILL.md file) rather than a gzip package, but the message is opaque — + // surfacing the HasBlob check up front gives the user a clearer answer. + skill, err := skillCtx.client.Get(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) + } + if !skill.HasBlob { + return exterrors.Validation( + exterrors.CodeSkillNoPackage, + fmt.Sprintf("skill %q has no downloadable package", a.flags.name), + "only skills created from a `.tar.gz` / `.tgz` archive have a downloadable "+ + "package. Use `azd ai skill show ` to inspect metadata; "+ + "re-create with `azd ai skill create --file .tar.gz --force` "+ + "if you want a downloadable copy.", + ) + } + body, err := skillCtx.client.Download(ctx, a.flags.name) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpDownloadSkill) diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go index 192bd112d2c..701828b7fd9 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go @@ -32,6 +32,7 @@ const ( CodeMissingForceFlag = "missing_force_flag" CodeSkillNotFound = "skill_not_found" CodeSkillAlreadyExists = "skill_already_exists" + CodeSkillNoPackage = "skill_no_package" ) // Error codes for auth. From 4237ac19434056e4dd4c50196bf0027c6a1d75ca Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 16:22:10 +0800 Subject: [PATCH 05/13] fix(skills): switch from gzip+tar to ZIP for package upload/download The live Foundry Skills service implements POST /skills:import and GET /skills/{name}:download with application/zip, not application/gzip as the upstream TypeSpec declares. Verified via 415 Unsupported Media Type on gzip uploads. Public docs confirm: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills Changes: - skill_api: replace archive/tar+compress/gzip with archive/zip - skill_api: Download now returns []byte (archive/zip needs io.ReaderAt) - skill_api: rename ContentTypeGzip -> ContentTypeZip, ErrInvalidGzip -> ErrInvalidZip - cmd: accept '.zip' for --file; reject '.tar.gz'/'.tgz' - cmd: writeRaw now writes '.zip' - tests: rewrite archive_test.go and archive_peek_test.go for ZIP - docs (AGENTS.md, README.md, CHANGELOG.md): s/gzip,tar.gz/zip/g The design spec (PR #8204) will need a follow-up to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.skills/AGENTS.md | 8 +- .../extensions/azure.ai.skills/CHANGELOG.md | 4 +- cli/azd/extensions/azure.ai.skills/README.md | 2 +- .../azure.ai.skills/internal/cmd/root.go | 2 +- .../internal/cmd/skill_create.go | 35 ++-- .../internal/cmd/skill_download.go | 32 ++- .../internal/cmd/skill_update.go | 8 +- .../internal/cmd/skill_validate_test.go | 6 +- .../internal/exterrors/codes.go | 2 +- .../internal/pkg/skill_api/archive.go | 169 +++++++++------ .../internal/pkg/skill_api/archive_peek.go | 69 +++--- .../pkg/skill_api/archive_peek_test.go | 77 +++---- .../internal/pkg/skill_api/archive_test.go | 197 +++++++----------- .../internal/pkg/skill_api/client.go | 48 +++-- .../internal/pkg/skill_api/client_test.go | 18 +- .../internal/pkg/skill_api/models.go | 4 +- 16 files changed, 313 insertions(+), 368 deletions(-) diff --git a/cli/azd/extensions/azure.ai.skills/AGENTS.md b/cli/azd/extensions/azure.ai.skills/AGENTS.md index 8ac93592d31..6539feadfbb 100644 --- a/cli/azd/extensions/azure.ai.skills/AGENTS.md +++ b/cli/azd/extensions/azure.ai.skills/AGENTS.md @@ -9,7 +9,7 @@ Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root Useful places to start: - `internal/cmd/`: Cobra commands and top-level orchestration -- `internal/pkg/skill_api/`: typed Foundry Skills REST client, models, SKILL.md parser, and safe tar.gz extractor +- `internal/pkg/skill_api/`: typed Foundry Skills REST client, models, SKILL.md parser, and safe ZIP extractor - `internal/exterrors/`: structured error factories and extension-specific codes ## Relationship to `azure.ai.agents` @@ -64,9 +64,9 @@ Each `--debug` run writes to `azd-ai-skills-.log` in the current working d ## File handling - `--file` is **not** a manifest. It is read at invocation time only; the CLI does not track or re-read it after the command returns. -- `create`: accepts `.md`, `.tar.gz`, or `.tgz`. Mode is inferred from extension; conflicting modes (inline + `--file`) are rejected. -- `update`: accepts `.md` only. `.tar.gz`/`.tgz` is rejected with a structured suggestion to use `create --force`. -- `download`: writes either an extracted directory (default) or the unmodified gzip archive (`--raw`). +- `create`: accepts `.md` or `.zip`. Mode is inferred from extension; conflicting modes (inline + `--file`) are rejected. +- `update`: accepts `.md` only. `.zip` is rejected with a structured suggestion to use `create --force`. +- `download`: writes either an extracted directory (default) or the unmodified ZIP archive (`--raw`). ## Release preparation diff --git a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md index eb2c71cf03e..aadac36359f 100644 --- a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md @@ -5,12 +5,12 @@ - Initial preview release of the `azure.ai.skills` extension. - Adds the `azd ai skill` command group with full CRUD over Foundry Skills: - `azd ai skill create ` — inline (`--description` + `--instructions`), - SKILL.md file (`--file ./SKILL.md`), or gzip package (`--file ./skill.tar.gz`). + SKILL.md file (`--file ./SKILL.md`), or ZIP package (`--file ./skill.zip`). - `azd ai skill update ` — inline or `--file *.md`. - `azd ai skill show ` — metadata only. - `azd ai skill list` — paginated, supports `--top` and `--orderby`. - `azd ai skill download ` — extracts to `./.agents/skills//` by - default, `--raw` keeps the gzip archive. + default, `--raw` keeps the ZIP archive. - `azd ai skill delete ` — confirmation by default, `--force` to skip. - Shares the Foundry project-endpoint resolution cascade with `azure.ai.agents`, reading `extensions.ai-skills.project.context.endpoint` first and falling back to diff --git a/cli/azd/extensions/azure.ai.skills/README.md b/cli/azd/extensions/azure.ai.skills/README.md index b4d5354e8f4..1aa1e50e3d9 100644 --- a/cli/azd/extensions/azure.ai.skills/README.md +++ b/cli/azd/extensions/azure.ai.skills/README.md @@ -9,7 +9,7 @@ terminal. ```bash azd ai skill create [--description "..." --instructions "..."] azd ai skill create --file ./SKILL.md -azd ai skill create --file ./skill.tar.gz +azd ai skill create --file ./skill.zip azd ai skill update [--description "..."] [--instructions "..."] [--file ./SKILL.md] azd ai skill show diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go index d42160f2c05..68c43ab0924 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go @@ -27,7 +27,7 @@ func NewRootCommand() *cobra.Command { at runtime — from your terminal. Skills carry either inline JSON (description + Markdown instructions) or a -packaged gzip tarball bundling SKILL.md plus any sibling assets. Use this +packaged ZIP archive bundling SKILL.md plus any sibling assets. Use this command group to create, update, show, list, download, and delete skills in a Foundry project.`, }) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go index d9f96be9a1f..a6ac683ed04 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -183,8 +183,8 @@ func (a *createAction) runFilePackage(ctx context.Context, client *skill_api.Cli if info.IsDir() { return exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf("--file %s is a directory; expected a .tar.gz / .tgz archive", a.flags.file), - "pass a single gzip archive path", + fmt.Sprintf("--file %s is a directory; expected a .zip archive", a.flags.file), + "pass a single .zip archive path", ) } @@ -213,7 +213,7 @@ func printCreateResult(s *skill_api.Skill, format string) error { return printSkillDetail(s, outputTable) } -// verifyPackageNameMatches peeks SKILL.md from a .tar.gz archive and ensures +// verifyPackageNameMatches peeks SKILL.md from a .zip archive and ensures // its front-matter `name` (when present) matches the positional argument the // user supplied. This guards `--force` in package mode against the // destructive sequence "delete positional name, then upload archive that @@ -225,22 +225,21 @@ func printCreateResult(s *skill_api.Skill, format string) error { // archives), or when the embedded name matches positionalName. Returns a // structured validation error otherwise. func verifyPackageNameMatches(archivePath, positionalName string) error { - f, openErr := os.Open(archivePath) //nolint:gosec // user-supplied path opened on their behalf - if openErr != nil { + data, readErr := os.ReadFile(archivePath) //nolint:gosec // user-supplied path read on their behalf + if readErr != nil { return exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf("cannot open %s: %s", archivePath, openErr), + fmt.Sprintf("cannot read %s: %s", archivePath, readErr), "verify the file is readable", ) } - defer f.Close() - archiveName, peekErr := skill_api.PeekArchiveSkillName(f) + archiveName, peekErr := skill_api.PeekArchiveSkillName(data) if peekErr != nil { return exterrors.Validation( exterrors.CodeInvalidSkillFile, fmt.Sprintf("cannot inspect %s: %s", archivePath, peekErr), - "ensure the archive is a valid .tar.gz containing a SKILL.md file", + "ensure the archive is a valid .zip containing a SKILL.md file", ) } if archiveName == "" || archiveName == positionalName { @@ -281,19 +280,17 @@ func selectCreateMode(f *createFlags) (createMode, error) { if fileProvided { ext := strings.ToLower(filepath.Ext(f.file)) - // `.tgz` and `.tar.gz` both work for packages; `.md` for inline. - switch { - case ext == ".md": + // `.zip` for packages; `.md` for SKILL.md inline body. + switch ext { + case ".md": return modeFileMd, nil - case ext == ".tgz": - return modeFilePackage, nil - case strings.HasSuffix(strings.ToLower(f.file), ".tar.gz"): + case ".zip": return modeFilePackage, nil default: return modeNone, exterrors.Validation( exterrors.CodeInvalidSkillFile, fmt.Sprintf("unsupported --file extension %q", ext), - "use .md for inline metadata, or .tar.gz / .tgz for a package upload", + "use .md for inline metadata or .zip for a package upload", ) } } @@ -362,12 +359,12 @@ func newCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { 1. Inline: --description "..." --instructions "..." 2. SKILL.md: --file ./SKILL.md (CLI parses YAML front matter + body) - 3. Package: --file ./skill.tar.gz (CLI uploads the archive as-is) + 3. Package: --file ./skill.zip (CLI uploads the archive as-is) Pass --force to delete an existing skill of the same name before creating.`, Example: ` azd ai skill create greet-user --description "Welcomes a new user" --instructions "Greet ..." azd ai skill create greet-user --file ./SKILL.md - azd ai skill create greet-user --file ./skill.tar.gz --force`, + azd ai skill create greet-user --file ./skill.zip --force`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { flags.name = args[0] @@ -387,7 +384,7 @@ Pass --force to delete an existing skill of the same name before creating.`, cmd.Flags().StringVar(&flags.instructions, "instructions", "", "Inline mode: Markdown body defining skill behavior") cmd.Flags().StringVar(&flags.file, "file", "", - "Path to SKILL.md (.md) or a gzip package (.tar.gz / .tgz)") + "Path to SKILL.md (.md) or a ZIP package (.zip)") cmd.Flags().BoolVar(&flags.force, "force", false, "Delete an existing skill of the same name before creating") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go index 9583b6dc7d7..607c48f8d00 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "io" "os" "path/filepath" @@ -72,7 +71,7 @@ func (a *downloadAction) Run(ctx context.Context) error { // Pre-flight via Get so we can detect the "no associated package" case // before issuing the :download call. The service returns 404 with a // dedicated error code when the skill was created from inline JSON (or a - // SKILL.md file) rather than a gzip package, but the message is opaque — + // SKILL.md file) rather than a ZIP package, but the message is opaque — // surfacing the HasBlob check up front gives the user a clearer answer. skill, err := skillCtx.client.Get(ctx, a.flags.name) if err != nil { @@ -82,9 +81,9 @@ func (a *downloadAction) Run(ctx context.Context) error { return exterrors.Validation( exterrors.CodeSkillNoPackage, fmt.Sprintf("skill %q has no downloadable package", a.flags.name), - "only skills created from a `.tar.gz` / `.tgz` archive have a downloadable "+ + "only skills created from a `.zip` archive have a downloadable "+ "package. Use `azd ai skill show ` to inspect metadata; "+ - "re-create with `azd ai skill create --file .tar.gz --force` "+ + "re-create with `azd ai skill create --file .zip --force` "+ "if you want a downloadable copy.", ) } @@ -93,7 +92,6 @@ func (a *downloadAction) Run(ctx context.Context) error { if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpDownloadSkill) } - defer body.Close() if a.flags.raw { return a.writeRaw(body, absOut) @@ -101,12 +99,12 @@ func (a *downloadAction) Run(ctx context.Context) error { return a.writeExtracted(body, absOut) } -func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error { +func (a *downloadAction) writeRaw(body []byte, outputDir string) error { if err := os.MkdirAll(outputDir, 0700); err != nil { return fmt.Errorf("create output dir: %w", err) } - archivePath := filepath.Join(outputDir, a.flags.name+".tar.gz") + archivePath := filepath.Join(outputDir, a.flags.name+".zip") // Always Lstat (even with --force) so we never follow a symlink and so we // refuse to overwrite a non-regular file (directory, device, socket, ...). @@ -151,7 +149,7 @@ func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error { if err != nil { return fmt.Errorf("create archive: %w", err) } - if _, copyErr := io.Copy(f, body); copyErr != nil { + if _, copyErr := f.Write(body); copyErr != nil { _ = f.Close() return fmt.Errorf("write archive: %w", copyErr) } @@ -162,13 +160,13 @@ func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error { res := downloadResult{ Skill: a.flags.name, OutputDir: outputDir, - Archive: a.flags.name + ".tar.gz", + Archive: a.flags.name + ".zip", Raw: true, } return a.printResult(res) } -func (a *downloadAction) writeExtracted(body io.Reader, outputDir string) error { +func (a *downloadAction) writeExtracted(body []byte, outputDir string) error { result, err := skill_api.SafeExtract(body, skill_api.ExtractOptions{ OutputDir: outputDir, Force: a.flags.force, @@ -224,11 +222,11 @@ func classifyExtractError(err error, outputDir string) error { err.Error(), fmt.Sprintf("pass --force to overwrite existing files in %s", outputDir), ) - case errors.Is(err, skill_api.ErrInvalidGzip): + case errors.Is(err, skill_api.ErrInvalidZip): return exterrors.Validation( exterrors.CodeInvalidParameter, err.Error(), - "the service did not return a gzip archive; retry or contact support", + "the service did not return a valid zip archive; retry or contact support", ) } return err @@ -242,15 +240,15 @@ func newDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "download ", Short: "Download a Foundry skill package.", - Long: `Download a skill's gzip package. + Long: `Download a skill's ZIP package. By default the CLI extracts the archive into --output-dir (which defaults to -'./.agents/skills//'). Pass --raw to write the unmodified gzip archive +'./.agents/skills//'). Pass --raw to write the unmodified ZIP archive into --output-dir instead. Extraction enforces strict safety rules: no absolute paths, no '..' segments, -no symlinks, no hard links, no non-regular files, and a 10,000-entry / -512 MB cap on the total uncompressed size.`, +no symlinks / non-regular entries, and a 10,000-entry / 512 MB cap on the +total uncompressed size.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { flags.name = args[0] @@ -266,7 +264,7 @@ no symlinks, no hard links, no non-regular files, and a 10,000-entry / cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", "Directory to write the extracted skill (default: ./.agents/skills//)") cmd.Flags().BoolVar(&flags.raw, "raw", false, - "Skip extraction; write the gzip archive as-is to --output-dir") + "Skip extraction; write the ZIP archive as-is to --output-dir") cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite existing files in --output-dir") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go index 2e55b94fac4..9301782004c 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -131,11 +131,11 @@ func (a *updateAction) validateFlags() error { switch { case ext == ".md": return nil - case ext == ".tgz", strings.HasSuffix(strings.ToLower(a.flags.file), ".tar.gz"): + case ext == ".zip": return exterrors.Validation( exterrors.CodeInvalidSkillFile, - "gzip packages cannot be applied via `skill update`", - "use `azd ai skill create --file .tar.gz --force` to replace the skill", + "ZIP packages cannot be applied via `skill update`", + "use `azd ai skill create --file .zip --force` to replace the skill", ) default: return exterrors.Validation( @@ -161,7 +161,7 @@ func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Pass any subset of: --description "..." --instructions "..." or: - --file ./SKILL.md (parsed locally; .tar.gz / .tgz is not accepted here) + --file ./SKILL.md (parsed locally; .zip is not accepted here) The CLI fetches the current skill, merges your changes locally, then POSTs the merged payload to the service.`, diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go index 3f24b8f1ca8..e495d861388 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go @@ -71,7 +71,7 @@ func TestSelectCreateMode_FileMd(t *testing.T) { } func TestSelectCreateMode_FilePackage(t *testing.T) { - for _, f := range []string{"./pkg.tar.gz", "./pkg.tgz", "./PKG.TGZ"} { + for _, f := range []string{"./pkg.zip", "./PKG.ZIP"} { mode, err := selectCreateMode(&createFlags{file: f}) require.NoError(t, err, "file %q", f) require.Equal(t, modeFilePackage, mode, "file %q", f) @@ -116,8 +116,8 @@ func TestUpdateAction_ConflictingArgs(t *testing.T) { require.Equal(t, exterrors.CodeConflictingArguments, le.Code) } -func TestUpdateAction_RejectsGzipFile(t *testing.T) { - for _, f := range []string{"./pkg.tar.gz", "./pkg.tgz"} { +func TestUpdateAction_RejectsZipFile(t *testing.T) { + for _, f := range []string{"./pkg.zip", "./PKG.ZIP"} { a := &updateAction{flags: &updateFlags{file: f}} err := a.validateFlags() require.Errorf(t, err, "file %q", f) diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go index 701828b7fd9..a80a359054b 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go @@ -15,7 +15,7 @@ const ( // unreadable, or unsupported file, or when SKILL.md front matter // fails to parse. CodeInvalidSkillFile = "invalid_skill_file" - // CodeSkillArchiveUnsafe is used when a downloaded gzip archive + // CodeSkillArchiveUnsafe is used when a downloaded ZIP archive // contains an unsafe entry (zip-slip, symlink, oversized, etc.). CodeSkillArchiveUnsafe = "skill_archive_unsafe" // CodeSkillOutputCollision is used when `skill download` would diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index 25d04eb5e6a..096e98aea25 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -4,8 +4,7 @@ package skill_api import ( - "archive/tar" - "compress/gzip" + "archive/zip" "errors" "fmt" "io" @@ -30,7 +29,7 @@ type ExtractOptions struct { // SafeExtract returns ErrCollision listing the first collision encountered // and writes nothing. Force bool - // MaxEntries caps the number of tar entries processed. Zero falls back to + // MaxEntries caps the number of zip entries processed. Zero falls back to // DefaultMaxEntries. MaxEntries int // MaxTotalUncompressed caps the total uncompressed byte count across all @@ -50,9 +49,9 @@ type ExtractResult struct { // Sentinel errors. Each wraps additional context describing the offending // entry / collision so callers can include it in the user-facing message. -// ErrUnsafeEntry indicates a tar entry was rejected (absolute path, -// `..` component, symlink, hard link, non-regular file, or empty/`/`-only name). -var ErrUnsafeEntry = errors.New("unsafe tar entry") +// ErrUnsafeEntry indicates a zip entry was rejected (absolute path, +// `..` component, irregular file mode, or empty/`/`-only name). +var ErrUnsafeEntry = errors.New("unsafe zip entry") // ErrLimitExceeded indicates the entry count or uncompressed byte limit was // exceeded mid-extraction. @@ -62,34 +61,35 @@ var ErrLimitExceeded = errors.New("archive exceeds safety limit") // exists in OutputDir and Force was not set. var ErrCollision = errors.New("output collision") -// ErrInvalidGzip is returned when the response is not a valid gzip stream. -var ErrInvalidGzip = errors.New("invalid gzip stream") +// ErrInvalidZip is returned when the response body is not a valid zip stream. +var ErrInvalidZip = errors.New("invalid zip stream") -// SafeExtract reads a gzipped tar stream from r and writes its regular-file -// contents under opts.OutputDir. The implementation is two-phase: +// SafeExtract reads a ZIP archive from data (already buffered in memory or +// backed by a ReaderAt) and writes its regular-file contents under +// opts.OutputDir. The implementation is two-phase: // -// 1. Stream the archive entries into a temporary staging directory under the -// OS temp dir, validating each entry against the safety rules before +// 1. Walk every zip entry into a temporary staging directory under the OS +// temp dir, validating each entry against the safety rules before // writing anything. -// 2. After every entry has been written to staging, copy each file into the +// 2. After every entry has been written to staging, verify no destination +// escapes opts.OutputDir via symlinks, then copy each file into the // final OutputDir. If anything fails partway, the staging directory is // removed and nothing is left behind in OutputDir. // // Safety rules (each rejection returns ErrUnsafeEntry): // // - Absolute paths or paths containing `..` components are rejected. -// - Symbolic links and hard links are rejected. -// - Non-regular and non-directory entries (devices, FIFOs, sockets) are -// rejected. // - Empty names, or names that collapse to "/" or "." after cleaning, are // rejected. +// - Non-regular files (modes with symlink/device/socket bits set) are +// rejected. // - Total entry count is capped at opts.MaxEntries (default 10,000). // - Total uncompressed byte count is capped at opts.MaxTotalUncompressed // (default 512 MB). // -// Executable bits from tar headers are dropped; written files use 0600 / 0700 +// Executable bits from zip headers are dropped; written files use 0600 / 0700 // modes against the process umask. -func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { +func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { if opts.OutputDir == "" { return nil, fmt.Errorf("SafeExtract: OutputDir is required") } @@ -102,11 +102,14 @@ func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { maxBytes = DefaultMaxTotalUncompressed } - gz, err := gzip.NewReader(r) + zr, err := newZipReader(data) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrInvalidGzip, err) + return nil, err + } + + if len(zr.File) > maxEntries { + return nil, fmt.Errorf("%w: entry count %d exceeds %d", ErrLimitExceeded, len(zr.File), maxEntries) } - defer gz.Close() staging, err := os.MkdirTemp("", "azd-skill-extract-*") if err != nil { @@ -116,52 +119,32 @@ func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { _ = os.RemoveAll(staging) } - tr := tar.NewReader(gz) var files []string var totalBytes int64 - entryCount := 0 - for { - hdr, hdrErr := tr.Next() - if errors.Is(hdrErr, io.EOF) { - break - } - if hdrErr != nil { - cleanupStaging() - return nil, fmt.Errorf("read tar entry: %w", hdrErr) - } - - entryCount++ - if entryCount > maxEntries { - cleanupStaging() - return nil, fmt.Errorf("%w: entry count exceeds %d", ErrLimitExceeded, maxEntries) - } - - cleaned, ok := validateEntryName(hdr.Name) + for _, entry := range zr.File { + cleaned, ok := validateEntryName(entry.Name) if !ok { cleanupStaging() - return nil, fmt.Errorf("%w: %q", ErrUnsafeEntry, hdr.Name) + return nil, fmt.Errorf("%w: %q", ErrUnsafeEntry, entry.Name) } - switch hdr.Typeflag { - case tar.TypeReg, tar.TypeRegA: - // Fall through. - case tar.TypeDir: + mode := entry.Mode() + switch { + case mode.IsDir() || strings.HasSuffix(entry.Name, "/"): dirPath := filepath.Join(staging, filepath.FromSlash(cleaned)) if mkErr := os.MkdirAll(dirPath, 0700); mkErr != nil { cleanupStaging() return nil, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) } continue + case mode.IsRegular(): + // Fall through. default: cleanupStaging() - return nil, fmt.Errorf("%w: %q has unsupported tar type %c", ErrUnsafeEntry, hdr.Name, hdr.Typeflag) + return nil, fmt.Errorf("%w: %q has irregular file mode %v", ErrUnsafeEntry, entry.Name, mode) } - // Reject explicit zero-size entries that the spec only allows for - // directories. Regular files with hdr.Size == 0 are fine (empty files). - - // Pre-create parent directory in staging. stagingPath := filepath.Join(staging, filepath.FromSlash(cleaned)) if mkErr := os.MkdirAll(filepath.Dir(stagingPath), 0700); mkErr != nil { cleanupStaging() @@ -169,31 +152,42 @@ func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { } remaining := maxBytes - totalBytes - if hdr.Size > 0 && hdr.Size > remaining { + // entry.UncompressedSize64 is advisory; we cap the actual reader so a + // lying header cannot exhaust disk. + if entry.UncompressedSize64 > 0 && int64(entry.UncompressedSize64) > remaining { //nolint:gosec // bound-checked just above cleanupStaging() return nil, fmt.Errorf("%w: uncompressed size would exceed %d bytes", ErrLimitExceeded, maxBytes) } - // Cap reading at the remaining budget so a lying header cannot run away. - limited := io.LimitReader(tr, remaining+1) + rc, openErr := entry.Open() + if openErr != nil { + cleanupStaging() + return nil, fmt.Errorf("open zip entry %q: %w", cleaned, openErr) + } f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // stagingPath is inside our trusted staging dir if fErr != nil { + _ = rc.Close() cleanupStaging() return nil, fmt.Errorf("create staging file %q: %w", cleaned, fErr) } - written, copyErr := io.Copy(f, limited) - closeErr := f.Close() - if copyErr != nil { + // Cap reading at remaining+1: if we copy more than `remaining` the + // limit was violated. + written, copyErr := io.Copy(f, io.LimitReader(rc, remaining+1)) + closeRcErr := rc.Close() + closeFErr := f.Close() + switch { + case copyErr != nil: cleanupStaging() return nil, fmt.Errorf("write %q: %w", cleaned, copyErr) - } - if closeErr != nil { + case closeRcErr != nil: cleanupStaging() - return nil, fmt.Errorf("close staging file %q: %w", cleaned, closeErr) - } - if written > remaining { + return nil, fmt.Errorf("close zip entry %q: %w", cleaned, closeRcErr) + case closeFErr != nil: + cleanupStaging() + return nil, fmt.Errorf("close staging file %q: %w", cleaned, closeFErr) + case written > remaining: cleanupStaging() return nil, fmt.Errorf("%w: uncompressed size would exceed %d bytes", ErrLimitExceeded, maxBytes) } @@ -275,7 +269,20 @@ func SafeExtract(r io.Reader, opts ExtractOptions) (*ExtractResult, error) { }, nil } -// validateEntryName cleans and validates a tar entry name. It returns the +// newZipReader builds an in-memory zip.Reader from data. Returns ErrInvalidZip +// wrapped in any underlying parse error. +func newZipReader(data []byte) (*zip.Reader, error) { + if len(data) == 0 { + return nil, fmt.Errorf("%w: empty body", ErrInvalidZip) + } + zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidZip, err) + } + return zr, nil +} + +// validateEntryName cleans and validates a zip entry name. It returns the // cleaned, slash-rooted relative path on success (no leading slash, no `..` // segments). // @@ -287,27 +294,33 @@ func validateEntryName(name string) (string, bool) { if name == "" { return "", false } - // Tar entry names use forward slashes. Normalize to slashes before cleaning + // Zip entry names use forward slashes. Normalize to slashes before cleaning // so we behave the same on Windows and Unix. slashed := strings.ReplaceAll(name, "\\", "/") + // Strip a single trailing slash for directory entries — keep the form for + // detection but don't fail validation on the slash itself. + withoutTrailing := strings.TrimSuffix(slashed, "/") + if withoutTrailing == "" { + return "", false + } // Reject Windows drive-letter syntax masquerading as a relative path. - if len(slashed) >= 2 && slashed[1] == ':' { + if len(withoutTrailing) >= 2 && withoutTrailing[1] == ':' { return "", false } // Reject absolute paths. - if strings.HasPrefix(slashed, "/") { + if strings.HasPrefix(withoutTrailing, "/") { return "", false } // Reject any `..` segment in the *raw* name. This is stricter than // path.Clean — we want to refuse archives that even attempt traversal. - for _, part := range strings.Split(slashed, "/") { + for _, part := range strings.Split(withoutTrailing, "/") { if part == ".." { return "", false } } // path.Clean to remove redundant separators and resolve `.` segments // without traversing the filesystem. - cleaned := path.Clean(slashed) + cleaned := path.Clean(withoutTrailing) if cleaned == "" || cleaned == "." || cleaned == "/" { return "", false } @@ -342,3 +355,25 @@ func copyFile(src, dst string) error { } return out.Close() } + +// bytesReaderAt is a tiny io.ReaderAt over a byte slice. We avoid pulling in +// bytes.Reader so callers can pass slices that may grow without re-allocating +// the wrapper (zip.NewReader only needs ReadAt + length). +type bytesReaderAt struct { + data []byte +} + +func newBytesReaderAt(data []byte) *bytesReaderAt { + return &bytesReaderAt{data: data} +} + +func (b *bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { + if off < 0 || off > int64(len(b.data)) { + return 0, io.EOF + } + n := copy(p, b.data[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go index 19fdc5a2668..a5f03f1ca3a 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -4,10 +4,6 @@ package skill_api import ( - "archive/tar" - "compress/gzip" - "errors" - "fmt" "io" "path" "strings" @@ -19,65 +15,56 @@ import ( // exhaust memory during the front-matter peek. const peekMaxSkillMdBytes = 1 << 20 // 1 MiB -// peekMaxEntries caps how many tar entries PeekArchiveSkillName scans before +// peekMaxEntries caps how many zip entries PeekArchiveSkillName scans before // giving up. SKILL.md is conventionally at the archive root, so scanning // every entry of a deeply-nested archive is unnecessary and would let a // malicious archive stall the CLI. const peekMaxEntries = 1024 -// PeekArchiveSkillName reads the gzipped tar stream from r and returns the -// `name` field declared in the archive's SKILL.md front matter. +// PeekArchiveSkillName reads the ZIP archive in data and returns the `name` +// field declared in the archive's SKILL.md front matter. // // Lookup rules (first match wins): // -// - A file whose cleaned tar entry name equals `SKILL.md`. -// - A file whose cleaned tar entry name is `/SKILL.md`, -// i.e. a SKILL.md exactly one directory below the archive root. This -// matches the layout produced by `azd ai skill package`. +// - A file whose cleaned entry name equals `SKILL.md`. +// - A file whose cleaned entry name is `/SKILL.md`, +// i.e. a SKILL.md exactly one directory below the archive root. // // Returns ("", nil) when no SKILL.md is found, when SKILL.md exists but does -// not declare a `name`, or when the front matter cannot be parsed. The -// caller is expected to treat an empty result as "no claim", not as an -// error: the destructive `--force` guard only fires when the archive makes -// a name claim that disagrees with the positional argument. +// not declare a `name`, or when the front matter cannot be parsed. The caller +// is expected to treat an empty result as "no claim", not as an error: the +// destructive `--force` guard only fires when the archive makes a name claim +// that disagrees with the positional argument. // // PeekArchiveSkillName never writes to disk and reads at most peekMaxSkillMdBytes // bytes from any single entry. It returns an error only for unrecoverable -// stream problems (invalid gzip, truncated tar header). -func PeekArchiveSkillName(r io.Reader) (string, error) { - gz, err := gzip.NewReader(r) +// stream problems (invalid zip). +func PeekArchiveSkillName(data []byte) (string, error) { + zr, err := newZipReader(data) if err != nil { - return "", fmt.Errorf("%w: %w", ErrInvalidGzip, err) + return "", err } - defer gz.Close() - tr := tar.NewReader(gz) - entryCount := 0 - for { - hdr, hdrErr := tr.Next() - if errors.Is(hdrErr, io.EOF) { + for i, entry := range zr.File { + if i >= peekMaxEntries { return "", nil } - if hdrErr != nil { - return "", fmt.Errorf("read tar entry: %w", hdrErr) - } - entryCount++ - if entryCount > peekMaxEntries { - return "", nil - } - if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + if !entry.Mode().IsRegular() { continue } - if !isSkillMdEntry(hdr.Name) { + if !isSkillMdEntry(entry.Name) { continue } - // Limit how much we read so a malicious archive cannot make us - // allocate gigabytes for a SKILL.md "file". - data, readErr := io.ReadAll(io.LimitReader(tr, peekMaxSkillMdBytes)) + rc, openErr := entry.Open() + if openErr != nil { + return "", nil + } + raw, readErr := io.ReadAll(io.LimitReader(rc, peekMaxSkillMdBytes)) + _ = rc.Close() if readErr != nil { - return "", fmt.Errorf("read SKILL.md from archive: %w", readErr) + return "", nil } - md, parseErr := ParseSkillMd(data) + md, parseErr := ParseSkillMd(raw) if parseErr != nil { // SKILL.md is present but malformed. The upload itself will fail // validation; for the --force guard we treat this as "no claim" @@ -87,16 +74,16 @@ func PeekArchiveSkillName(r io.Reader) (string, error) { } return md.Name, nil } + return "", nil } -// isSkillMdEntry reports whether the given tar entry name refers to a +// isSkillMdEntry reports whether the given zip entry name refers to a // SKILL.md file at the archive root or exactly one directory below it. func isSkillMdEntry(name string) bool { cleaned := path.Clean(strings.TrimLeft(name, "/")) if cleaned == "SKILL.md" { return true } - // Match `/SKILL.md` (exactly one directory deep). dir, file := path.Split(cleaned) if file != "SKILL.md" { return false diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go index a7d8b53898f..3cfc1c9303b 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go @@ -4,87 +4,66 @@ package skill_api import ( - "archive/tar" - "bytes" "testing" "github.com/stretchr/testify/require" ) func TestPeekArchiveSkillName_RootLevel(t *testing.T) { - bodies := map[string][]byte{ - "SKILL.md": []byte("---\nname: my-skill\ndescription: d\n---\nbody\n"), - } - entries := []tar.Header{ - {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, - } - got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, + {Name: "other.txt", Body: []byte("ignored")}, + }) + got, err := PeekArchiveSkillName(archive) require.NoError(t, err) - require.Equal(t, "my-skill", got) + require.Equal(t, "foo", got) } func TestPeekArchiveSkillName_OneDirDeep(t *testing.T) { - bodies := map[string][]byte{ - "pkg/SKILL.md": []byte("---\nname: nested-skill\ndescription: d\n---\nbody\n"), - } - entries := []tar.Header{ - {Name: "pkg/", Mode: 0755, Typeflag: tar.TypeDir}, - {Name: "pkg/SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, - } - got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + archive := makeZip(t, []zipEntry{ + {Name: "greeting/"}, + {Name: "greeting/SKILL.md", Body: []byte("---\nname: greeting\n---\nbody\n")}, + }) + got, err := PeekArchiveSkillName(archive) require.NoError(t, err) - require.Equal(t, "nested-skill", got) + require.Equal(t, "greeting", got) } func TestPeekArchiveSkillName_TooDeepIgnored(t *testing.T) { - bodies := map[string][]byte{ - "a/b/SKILL.md": []byte("---\nname: too-deep\ndescription: d\n---\nbody\n"), - } - entries := []tar.Header{ - {Name: "a/b/SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, - } - got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + archive := makeZip(t, []zipEntry{ + {Name: "a/b/SKILL.md", Body: []byte("---\nname: deep\n---\nbody\n")}, + }) + got, err := PeekArchiveSkillName(archive) require.NoError(t, err) require.Equal(t, "", got) } func TestPeekArchiveSkillName_NoSkillMd(t *testing.T) { - bodies := map[string][]byte{ - "readme.txt": []byte("hello"), - } - entries := []tar.Header{ - {Name: "readme.txt", Mode: 0644, Typeflag: tar.TypeReg}, - } - got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + archive := makeZip(t, []zipEntry{{Name: "README.md", Body: []byte("hi")}}) + got, err := PeekArchiveSkillName(archive) require.NoError(t, err) require.Equal(t, "", got) } func TestPeekArchiveSkillName_MissingNameField(t *testing.T) { - bodies := map[string][]byte{ - "SKILL.md": []byte("---\ndescription: no name here\n---\nbody\n"), - } - entries := []tar.Header{ - {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, - } - got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("---\ndescription: hi\n---\nbody\n")}, + }) + got, err := PeekArchiveSkillName(archive) require.NoError(t, err) require.Equal(t, "", got) } func TestPeekArchiveSkillName_MalformedYAMLReturnsEmpty(t *testing.T) { - bodies := map[string][]byte{ - "SKILL.md": []byte("not a valid skill md without front matter"), - } - entries := []tar.Header{ - {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, - } - got, err := PeekArchiveSkillName(bytes.NewReader(makeTarGz(t, entries, bodies))) + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("not valid front matter")}, + }) + got, err := PeekArchiveSkillName(archive) require.NoError(t, err) require.Equal(t, "", got) } -func TestPeekArchiveSkillName_InvalidGzip(t *testing.T) { - _, err := PeekArchiveSkillName(bytes.NewReader([]byte("this is not gzip"))) +func TestPeekArchiveSkillName_InvalidZip(t *testing.T) { + _, err := PeekArchiveSkillName([]byte("this is not zip")) require.Error(t, err) } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go index 2518b19222a..c1f171b4bfe 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -4,57 +4,64 @@ package skill_api import ( - "archive/tar" + "archive/zip" "bytes" - "compress/gzip" "errors" "os" "path/filepath" - "runtime" "testing" "github.com/stretchr/testify/require" ) -// makeTarGz builds a gzip-compressed tar archive in memory containing the -// provided entries. Each entry's TypeFlag drives the tar header type. -func makeTarGz(t *testing.T, entries []tar.Header, bodies map[string][]byte) []byte { +// zipEntry describes a single file or directory entry to add to a test +// archive. Body is ignored for directories (where Name ends with "/"). +type zipEntry struct { + Name string + Body []byte + // Mode is the file mode; when zero we default to 0644 for files and + // 0755 for directories. + Mode os.FileMode +} + +// makeZip builds an in-memory ZIP archive from entries. +func makeZip(t *testing.T, entries []zipEntry) []byte { t.Helper() var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - for _, h := range entries { - // Set Size from body when present. - if h.Typeflag == tar.TypeReg || h.Typeflag == tar.TypeRegA { - h.Size = int64(len(bodies[h.Name])) + zw := zip.NewWriter(&buf) + for _, e := range entries { + mode := e.Mode + isDir := mode.IsDir() || (len(e.Name) > 0 && e.Name[len(e.Name)-1] == '/') + if mode == 0 { + if isDir { + mode = os.ModeDir | 0755 + } else { + mode = 0644 + } } - require.NoError(t, tw.WriteHeader(&h)) - if body, ok := bodies[h.Name]; ok { - _, err := tw.Write(body) + hdr := &zip.FileHeader{Name: e.Name, Method: zip.Deflate} + hdr.SetMode(mode) + w, err := zw.CreateHeader(hdr) + require.NoError(t, err) + if !isDir && len(e.Body) > 0 { + _, err := w.Write(e.Body) require.NoError(t, err) } } - require.NoError(t, tw.Close()) - require.NoError(t, gz.Close()) + require.NoError(t, zw.Close()) return buf.Bytes() } func TestSafeExtract_HappyPath(t *testing.T) { - bodies := map[string][]byte{ - "SKILL.md": []byte("---\nname: foo\n---\nbody\n"), - "assets/icon.svg": []byte(""), - "assets/notes.txt": []byte("hello"), - } - entries := []tar.Header{ - {Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}, - {Name: "assets/", Mode: 0755, Typeflag: tar.TypeDir}, - {Name: "assets/icon.svg", Mode: 0644, Typeflag: tar.TypeReg}, - {Name: "assets/notes.txt", Mode: 0644, Typeflag: tar.TypeReg}, - } - archive := makeTarGz(t, entries, bodies) + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, + {Name: "assets/"}, + {Name: "assets/icon.svg", Body: []byte("")}, + {Name: "assets/notes.txt", Body: []byte("hello")}, + }) dir := t.TempDir() - res, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: dir}) + res, err := SafeExtract(archive, ExtractOptions{OutputDir: dir}) require.NoError(t, err) require.ElementsMatch(t, []string{"SKILL.md", "assets/icon.svg", "assets/notes.txt"}, res.Files) @@ -63,65 +70,46 @@ func TestSafeExtract_HappyPath(t *testing.T) { } func TestSafeExtract_RejectsDotDot(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: "../evil.txt", Mode: 0644, Typeflag: tar.TypeReg}}, - map[string][]byte{"../evil.txt": []byte("nope")}, - ) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + archive := makeZip(t, []zipEntry{{Name: "../evil.txt", Body: []byte("nope")}}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) require.True(t, errors.Is(err, ErrUnsafeEntry)) } func TestSafeExtract_RejectsAbsolutePath(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: "/etc/passwd", Mode: 0644, Typeflag: tar.TypeReg}}, - map[string][]byte{"/etc/passwd": []byte("nope")}, - ) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + archive := makeZip(t, []zipEntry{{Name: "/etc/passwd", Body: []byte("nope")}}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) require.True(t, errors.Is(err, ErrUnsafeEntry)) } func TestSafeExtract_RejectsSymlink(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: "link", Mode: 0777, Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}}, - nil, - ) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) - require.Error(t, err) - require.True(t, errors.Is(err, ErrUnsafeEntry)) -} - -func TestSafeExtract_RejectsHardLink(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: "link", Mode: 0644, Linkname: "target", Typeflag: tar.TypeLink}}, - nil, - ) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + // Build a zip entry whose mode declares a symlink. The CLI must refuse + // to extract this and must not follow the link target. + archive := makeZip(t, []zipEntry{{ + Name: "link", + Body: []byte("target"), + Mode: os.ModeSymlink | 0777, + }}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) require.True(t, errors.Is(err, ErrUnsafeEntry)) } func TestSafeExtract_RejectsWindowsBackslash(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: `..\evil.txt`, Mode: 0644, Typeflag: tar.TypeReg}}, - map[string][]byte{`..\evil.txt`: []byte("nope")}, - ) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: t.TempDir()}) + archive := makeZip(t, []zipEntry{{Name: `..\evil.txt`, Body: []byte("nope")}}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) require.True(t, errors.Is(err, ErrUnsafeEntry)) } func TestSafeExtract_EnforcesEntryCount(t *testing.T) { - headers := make([]tar.Header, 5) - bodies := map[string][]byte{} - for i := range headers { - name := filepath.ToSlash(filepath.Join("entry", string(rune('a'+i))+".txt")) - headers[i] = tar.Header{Name: name, Mode: 0644, Typeflag: tar.TypeReg} - bodies[name] = []byte{} + entries := make([]zipEntry, 5) + for i := range entries { + entries[i] = zipEntry{Name: filepath.ToSlash(filepath.Join("entry", string(rune('a'+i))+".txt"))} } - archive := makeTarGz(t, headers, bodies) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{ + archive := makeZip(t, entries) + _, err := SafeExtract(archive, ExtractOptions{ OutputDir: t.TempDir(), MaxEntries: 2, }) @@ -131,11 +119,8 @@ func TestSafeExtract_EnforcesEntryCount(t *testing.T) { func TestSafeExtract_EnforcesTotalSize(t *testing.T) { body := bytes.Repeat([]byte("a"), 1024) - archive := makeTarGz(t, - []tar.Header{{Name: "big.txt", Mode: 0644, Typeflag: tar.TypeReg}}, - map[string][]byte{"big.txt": body}, - ) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{ + archive := makeZip(t, []zipEntry{{Name: "big.txt", Body: body}}) + _, err := SafeExtract(archive, ExtractOptions{ OutputDir: t.TempDir(), MaxTotalUncompressed: 100, }) @@ -144,14 +129,11 @@ func TestSafeExtract_EnforcesTotalSize(t *testing.T) { } func TestSafeExtract_RejectsCollisionWithoutForce(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}}, - map[string][]byte{"SKILL.md": []byte("body")}, - ) + archive := makeZip(t, []zipEntry{{Name: "SKILL.md", Body: []byte("body")}}) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("old"), 0600)) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: dir}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: dir}) require.Error(t, err) require.True(t, errors.Is(err, ErrCollision)) @@ -161,24 +143,27 @@ func TestSafeExtract_RejectsCollisionWithoutForce(t *testing.T) { } func TestSafeExtract_ForceOverwrites(t *testing.T) { - archive := makeTarGz(t, - []tar.Header{{Name: "SKILL.md", Mode: 0644, Typeflag: tar.TypeReg}}, - map[string][]byte{"SKILL.md": []byte("new")}, - ) + archive := makeZip(t, []zipEntry{{Name: "SKILL.md", Body: []byte("new")}}) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("old"), 0600)) - _, err := SafeExtract(bytes.NewReader(archive), ExtractOptions{OutputDir: dir, Force: true}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: dir, Force: true}) require.NoError(t, err) got, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")) //nolint:gosec // test artifact require.Equal(t, "new", string(got)) } -func TestSafeExtract_InvalidGzip(t *testing.T) { - _, err := SafeExtract(bytes.NewReader([]byte("not a gzip stream")), ExtractOptions{OutputDir: t.TempDir()}) +func TestSafeExtract_InvalidZip(t *testing.T) { + _, err := SafeExtract([]byte("not a zip stream"), ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) - require.True(t, errors.Is(err, ErrInvalidGzip)) + require.True(t, errors.Is(err, ErrInvalidZip)) +} + +func TestSafeExtract_EmptyBody(t *testing.T) { + _, err := SafeExtract(nil, ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrInvalidZip)) } func TestValidateEntryName(t *testing.T) { @@ -190,6 +175,7 @@ func TestValidateEntryName(t *testing.T) { {"SKILL.md", "SKILL.md", true}, {"assets/icon.svg", "assets/icon.svg", true}, {"./SKILL.md", "SKILL.md", true}, + {"assets/", "assets", true}, {"", "", false}, {".", "", false}, {"/", "", false}, @@ -204,42 +190,3 @@ func TestValidateEntryName(t *testing.T) { require.Equal(t, c.want, got, "input=%q", c.in) } } - -// TestSafeExtract_RejectsSymlinkParentEscape verifies that SafeExtract refuses -// to write a file when a path component of the destination is a pre-existing -// symlink that points outside opts.OutputDir. -func TestSafeExtract_RejectsSymlinkParentEscape(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("creating symlinks on Windows requires elevated privileges") - } - - outDir := t.TempDir() - escapeTarget := t.TempDir() // outside outDir - - // Create a legitimate subdirectory name inside outDir that is actually a - // symlink pointing to escapeTarget. - symlinkPath := filepath.Join(outDir, "subdir") - require.NoError(t, os.Symlink(escapeTarget, symlinkPath)) - - // Build a tar.gz with an entry "subdir/secret.txt". After extraction the - // copy phase would try to write outDir/subdir/secret.txt, which resolves - // via the symlink to escapeTarget/secret.txt — outside outDir. - archive := makeTarGz(t, []tar.Header{ - {Name: "subdir/secret.txt", Typeflag: tar.TypeReg, Mode: 0600}, - }, map[string][]byte{ - "subdir/secret.txt": []byte("secret"), - }) - - opts := ExtractOptions{ - OutputDir: outDir, - MaxEntries: 10, - MaxTotalUncompressed: 1 << 20, - } - _, err := SafeExtract(bytes.NewReader(archive), opts) - require.Error(t, err) - require.True(t, errors.Is(err, ErrUnsafeEntry), "expected ErrUnsafeEntry, got %v", err) - - // Verify the file was NOT written to the symlink target. - _, statErr := os.Stat(filepath.Join(escapeTarget, "secret.txt")) - require.True(t, os.IsNotExist(statErr), "file must not be written through symlink to escape target") -} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go index 86b8f74930e..70312c05dad 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -37,9 +37,12 @@ const ( // ContentTypeJSON is the request/response content type used for the JSON // surface. ContentTypeJSON = "application/json" - // ContentTypeGzip is the content type used by POST /skills:import (request) - // and by GET /skills/{name}:download (response). - ContentTypeGzip = "application/gzip" + // ContentTypeZip is the content type used by POST /skills:import (request) + // and by GET /skills/{name}:download (response). The TypeSpec describes + // the body as `bytes` with `Content-Type: application/gzip`, but the live + // service implements ZIP per the public docs — verified via 415 on gzip. + // See https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills. + ContentTypeZip = "application/zip" // BearerScope is the Azure AD scope used for the bearer-token policy. // Matches the scope used by the rest of the Foundry AI extension surface. @@ -129,21 +132,19 @@ func (c *Client) CreateInline(ctx context.Context, req CreateRequest) (*Skill, e return decodeSkill(resp.Body) } -// CreatePackage uploads a gzip archive to POST /skills:import. The CLI does -// not inspect the contents; server-side validation owns the archive. -// nameHint is currently unused because the service derives the skill name -// from the archive's SKILL.md, but it is forwarded as a query parameter when -// non-empty so future server-side enforcement can match it against the URL. +// CreatePackage uploads a ZIP archive to POST /skills:import. The CLI does +// not inspect the archive's contents beyond an optional name-claim peek for +// the --force guard; server-side validation owns archive contents otherwise. func (c *Client) CreatePackage(ctx context.Context, archive io.ReadSeeker, archiveSize int64) (*Skill, error) { httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.buildURL("/skills:import", nil)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } - if err := httpReq.SetBody(streaming(archive), ContentTypeGzip); err != nil { + if err := httpReq.SetBody(streaming(archive), ContentTypeZip); err != nil { return nil, fmt.Errorf("set request body: %w", err) } httpReq.Raw().ContentLength = archiveSize - httpReq.Raw().Header.Set("Content-Type", ContentTypeGzip) + httpReq.Raw().Header.Set("Content-Type", ContentTypeZip) addStandardHeaders(httpReq) resp, err := c.pipeline.Do(httpReq) @@ -311,35 +312,38 @@ func (c *Client) ListAll(ctx context.Context, opts ListOptions, limit int) ([]Sk } } -// Download streams the gzip archive for a skill. The returned ReadCloser -// surfaces the raw bytes returned by the service; the caller is responsible -// for closing it. The Content-Type header is validated to start with -// `application/gzip`. -func (c *Client) Download(ctx context.Context, name string) (io.ReadCloser, error) { +// Download fetches the ZIP archive for a skill. Returns the raw bytes for +// in-memory processing — zip needs an io.ReaderAt so streaming the body +// directly into archive/zip is not possible without buffering anyway. +// The Content-Type header is validated to start with `application/zip`. +func (c *Client) Download(ctx context.Context, name string) ([]byte, error) { httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, ":download", nil)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } addStandardHeaders(httpReq) - // Accept: */* (gzip). We do not want runtime to ask for JSON. - httpReq.Raw().Header.Set("Accept", ContentTypeGzip) + // Override the JSON Accept default with the archive content type. + httpReq.Raw().Header.Set("Accept", ContentTypeZip) resp, err := c.pipeline.Do(httpReq) if err != nil { return nil, fmt.Errorf("http: %w", err) } + defer resp.Body.Close() if !runtime.HasStatusCode(resp, http.StatusOK) { - defer resp.Body.Close() return nil, runtime.NewResponseError(resp) } - if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(strings.ToLower(ct), ContentTypeGzip) { - defer resp.Body.Close() - return nil, fmt.Errorf("unexpected download content type %q (want %s)", ct, ContentTypeGzip) + if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(strings.ToLower(ct), ContentTypeZip) { + return nil, fmt.Errorf("unexpected download content type %q (want %s)", ct, ContentTypeZip) } - return resp.Body, nil + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read download body: %w", err) + } + return body, nil } // --- URL and header helpers --- diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go index 11fd6123099..ce89a1b3fd0 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go @@ -74,7 +74,7 @@ func TestClient_CreateInline_SendsRequiredHeadersAndQuery(t *testing.T) { require.Equal(t, "from-server", skill.Description) } -func TestClient_CreatePackage_SendsGzipContentType(t *testing.T) { +func TestClient_CreatePackage_SendsZipContentType(t *testing.T) { var capturedCT string var capturedPath string var capturedBytes []byte @@ -88,12 +88,12 @@ func TestClient_CreatePackage_SendsGzipContentType(t *testing.T) { })) defer srv.Close() - payload := []byte("\x1f\x8b\x08\x00fake-gzip") + payload := []byte("PK\x03\x04fake-zip") c := newTestClient(t, srv) skill, err := c.CreatePackage(context.Background(), strings.NewReader(string(payload)), int64(len(payload))) require.NoError(t, err) require.Equal(t, "/skills:import", capturedPath) - require.Equal(t, ContentTypeGzip, capturedCT) + require.Equal(t, ContentTypeZip, capturedCT) require.Equal(t, payload, capturedBytes) require.True(t, skill.HasBlob) } @@ -158,7 +158,7 @@ func TestClient_Download_ValidatesContentType(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/skills/my-skill:download", r.URL.Path) w.Header().Set("Content-Type", "text/plain") - _, _ = io.WriteString(w, "not gzip") + _, _ = io.WriteString(w, "not zip") })) defer srv.Close() @@ -168,19 +168,17 @@ func TestClient_Download_ValidatesContentType(t *testing.T) { require.Contains(t, err.Error(), "unexpected download content type") } -func TestClient_Download_PassesThroughGzipStream(t *testing.T) { - payload := []byte("fake-gzip-bytes") +func TestClient_Download_ReturnsZipBytes(t *testing.T) { + payload := []byte("PK\x03\x04fake-zip-bytes") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", ContentTypeGzip) + w.Header().Set("Content-Type", ContentTypeZip) _, _ = w.Write(payload) })) defer srv.Close() c := newTestClient(t, srv) - rc, err := c.Download(context.Background(), "my-skill") + got, err := c.Download(context.Background(), "my-skill") require.NoError(t, err) - got, _ := io.ReadAll(rc) - _ = rc.Close() require.Equal(t, payload, got) } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go index 0b1db5b28f2..3f451fa9657 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go @@ -3,7 +3,7 @@ // Package skill_api provides a typed REST client for the Foundry Skills // data-plane surface, plus helpers for parsing SKILL.md files and safely -// extracting downloaded gzip skill packages. +// extracting downloaded ZIP skill packages. package skill_api // Skill is the metadata representation of a Foundry skill returned by @@ -16,7 +16,7 @@ type Skill struct { // Name is the unique skill name (validated client-side against the // alphanumeric-with-hyphens pattern; final decision lives in the service). Name string `json:"name"` - // HasBlob reports whether the skill was created from a gzip package. + // HasBlob reports whether the skill was created from a ZIP package. // Determines whether `download` returns useful content. HasBlob bool `json:"hasBlob"` // Description is a human-readable summary; optional. From 5b208945b4d4c0b689f2b57b90f3d90a531fca86 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 16:49:58 +0800 Subject: [PATCH 06/13] fix(skills): auto-detect ZIP vs gzip on download (service is asymmetric) The Foundry Skills surface is asymmetric on archive format: - POST /skills:import requires application/zip (gzip yields 415) - GET /skills/{name}:download returns application/gzip Make SafeExtract sniff magic bytes (PK or 1f 8b) and dispatch to either the zip or the gzip+tar handler. Download() now accepts both Content-Type values. The raw download filename uses .zip or .tar.gz based on the detected format. Also: - Add DetectArchiveFormat() public helper for callers that need the format (e.g. the --raw filename picker). - Add gitignore for local test artifacts (zips, tar.gz, debug logs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.skills/.gitignore | 9 + .../extensions/azure.ai.skills/CHANGELOG.md | 8 +- .../internal/cmd/skill_download.go | 27 +- .../internal/pkg/skill_api/archive.go | 373 ++++++++++-------- .../internal/pkg/skill_api/archive_peek.go | 11 +- .../internal/pkg/skill_api/archive_test.go | 53 ++- .../internal/pkg/skill_api/client.go | 34 +- 7 files changed, 319 insertions(+), 196 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.skills/.gitignore diff --git a/cli/azd/extensions/azure.ai.skills/.gitignore b/cli/azd/extensions/azure.ai.skills/.gitignore new file mode 100644 index 00000000000..6171e0ea543 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/.gitignore @@ -0,0 +1,9 @@ +# Local test artifacts +SKILL.md +test-skill/ +test-min/ +test-*/ +*.zip +*.tar.gz +azd-ai-skills-*.log +bin/ diff --git a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md index aadac36359f..3fb7a84548a 100644 --- a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md @@ -10,8 +10,10 @@ - `azd ai skill show ` — metadata only. - `azd ai skill list` — paginated, supports `--top` and `--orderby`. - `azd ai skill download ` — extracts to `./.agents/skills//` by - default, `--raw` keeps the ZIP archive. + default; `--raw` keeps the archive as-is. The downloader auto-detects ZIP + vs gzip-tar via magic bytes because the Foundry surface is asymmetric: + uploads require `application/zip`, downloads return `application/gzip`. - `azd ai skill delete ` — confirmation by default, `--force` to skip. - Shares the Foundry project-endpoint resolution cascade with `azure.ai.agents`, - reading `extensions.ai-skills.project.context.endpoint` first and falling back to - `extensions.ai-agents.project.context.endpoint`. + reading `extensions.ai-skills.project.context.endpoint` first and falling + back to `extensions.ai-agents.project.context.endpoint`. diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go index 607c48f8d00..5c6ae24b81f 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -104,7 +104,12 @@ func (a *downloadAction) writeRaw(body []byte, outputDir string) error { return fmt.Errorf("create output dir: %w", err) } - archivePath := filepath.Join(outputDir, a.flags.name+".zip") + // Pick a file extension that matches the actual archive bytes the service + // returned. The Foundry surface is asymmetric (uploads ZIP, downloads + // gzip) so we can't assume one extension. + ext := archiveExtension(skill_api.DetectArchiveFormat(body)) + archiveName := a.flags.name + ext + archivePath := filepath.Join(outputDir, archiveName) // Always Lstat (even with --force) so we never follow a symlink and so we // refuse to overwrite a non-regular file (directory, device, socket, ...). @@ -160,12 +165,26 @@ func (a *downloadAction) writeRaw(body []byte, outputDir string) error { res := downloadResult{ Skill: a.flags.name, OutputDir: outputDir, - Archive: a.flags.name + ".zip", + Archive: archiveName, Raw: true, } return a.printResult(res) } +// archiveExtension returns the on-disk file extension to use when writing a +// raw downloaded skill package. Falls back to ".bin" when the magic bytes +// don't match a known archive format so the file is still preserved. +func archiveExtension(format skill_api.ArchiveFormat) string { + switch format { + case skill_api.ArchiveZip: + return ".zip" + case skill_api.ArchiveTarGz: + return ".tar.gz" + default: + return ".bin" + } +} + func (a *downloadAction) writeExtracted(body []byte, outputDir string) error { result, err := skill_api.SafeExtract(body, skill_api.ExtractOptions{ OutputDir: outputDir, @@ -222,11 +241,11 @@ func classifyExtractError(err error, outputDir string) error { err.Error(), fmt.Sprintf("pass --force to overwrite existing files in %s", outputDir), ) - case errors.Is(err, skill_api.ErrInvalidZip): + case errors.Is(err, skill_api.ErrInvalidArchive): return exterrors.Validation( exterrors.CodeInvalidParameter, err.Error(), - "the service did not return a valid zip archive; retry or contact support", + "the service did not return a recognizable ZIP or gzip archive; retry or contact support", ) } return err diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index 096e98aea25..1a379dc3f15 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -4,7 +4,10 @@ package skill_api import ( + "archive/tar" "archive/zip" + "bytes" + "compress/gzip" "errors" "fmt" "io" @@ -14,85 +17,95 @@ import ( "strings" ) -// Default archive-safety limits. The spec caps decompression at 10,000 entries -// and 512 MB total uncompressed. Callers may override via ExtractOptions. const ( DefaultMaxEntries = 10_000 DefaultMaxTotalUncompressed = 512 * 1024 * 1024 ) -// ExtractOptions configures SafeExtract behavior. type ExtractOptions struct { - // OutputDir is the final destination directory. Created if missing. - OutputDir string - // Force allows overwriting existing files in OutputDir. When false, - // SafeExtract returns ErrCollision listing the first collision encountered - // and writes nothing. - Force bool - // MaxEntries caps the number of zip entries processed. Zero falls back to - // DefaultMaxEntries. - MaxEntries int - // MaxTotalUncompressed caps the total uncompressed byte count across all - // regular-file entries. Zero falls back to DefaultMaxTotalUncompressed. + OutputDir string + Force bool + MaxEntries int MaxTotalUncompressed int64 } -// ExtractResult is returned on success. type ExtractResult struct { - // Files is the list of regular files written, relative to OutputDir, - // in the order they were extracted. - Files []string - // TotalBytes is the cumulative uncompressed size of extracted files. + Files []string TotalBytes int64 } -// Sentinel errors. Each wraps additional context describing the offending -// entry / collision so callers can include it in the user-facing message. +var ( + ErrUnsafeEntry = errors.New("unsafe archive entry") + ErrLimitExceeded = errors.New("archive exceeds safety limit") + ErrCollision = errors.New("output collision") + ErrInvalidArchive = errors.New("invalid archive") +) -// ErrUnsafeEntry indicates a zip entry was rejected (absolute path, -// `..` component, irregular file mode, or empty/`/`-only name). -var ErrUnsafeEntry = errors.New("unsafe zip entry") +// ArchiveFormat identifies the wire format of a downloaded skill package. +type ArchiveFormat int -// ErrLimitExceeded indicates the entry count or uncompressed byte limit was -// exceeded mid-extraction. -var ErrLimitExceeded = errors.New("archive exceeds safety limit") +const ( + ArchiveUnknown ArchiveFormat = iota + ArchiveZip + ArchiveTarGz +) -// ErrCollision indicates the archive would overwrite a file that already -// exists in OutputDir and Force was not set. -var ErrCollision = errors.New("output collision") +func (f ArchiveFormat) String() string { + switch f { + case ArchiveZip: + return "zip" + case ArchiveTarGz: + return "tar.gz" + default: + return "unknown" + } +} -// ErrInvalidZip is returned when the response body is not a valid zip stream. -var ErrInvalidZip = errors.New("invalid zip stream") +// DetectArchiveFormat sniffs the first bytes of data to identify the archive +// format. The Foundry Skills service is asymmetric: it accepts ZIP on +// POST /skills:import but returns gzip on GET /skills/{name}:download, so we +// detect both rather than trust the Content-Type header. +func DetectArchiveFormat(data []byte) ArchiveFormat { + switch { + case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x03, 0x04}): + return ArchiveZip + case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x05, 0x06}): + return ArchiveZip // empty zip + case len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b: + return ArchiveTarGz + default: + return ArchiveUnknown + } +} -// SafeExtract reads a ZIP archive from data (already buffered in memory or -// backed by a ReaderAt) and writes its regular-file contents under -// opts.OutputDir. The implementation is two-phase: +// SafeExtract reads an archive (ZIP or gzip-compressed tar) from data and +// writes its regular-file contents under opts.OutputDir. // -// 1. Walk every zip entry into a temporary staging directory under the OS -// temp dir, validating each entry against the safety rules before -// writing anything. -// 2. After every entry has been written to staging, verify no destination -// escapes opts.OutputDir via symlinks, then copy each file into the -// final OutputDir. If anything fails partway, the staging directory is -// removed and nothing is left behind in OutputDir. +// The implementation is two-phase: every entry is first written into a +// temporary staging directory under the OS temp dir, validated against the +// safety rules, then copied into OutputDir. A failed extraction leaves +// nothing in OutputDir. // // Safety rules (each rejection returns ErrUnsafeEntry): // // - Absolute paths or paths containing `..` components are rejected. // - Empty names, or names that collapse to "/" or "." after cleaning, are // rejected. -// - Non-regular files (modes with symlink/device/socket bits set) are +// - Non-regular files (symlinks, hard links, devices, sockets) are // rejected. // - Total entry count is capped at opts.MaxEntries (default 10,000). // - Total uncompressed byte count is capped at opts.MaxTotalUncompressed // (default 512 MB). // -// Executable bits from zip headers are dropped; written files use 0600 / 0700 -// modes against the process umask. +// Executable bits from archive headers are dropped; written files use +// 0600 / 0700 modes against the process umask. func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { if opts.OutputDir == "" { return nil, fmt.Errorf("SafeExtract: OutputDir is required") } + if len(data) == 0 { + return nil, fmt.Errorf("%w: empty body", ErrInvalidArchive) + } maxEntries := opts.MaxEntries if maxEntries <= 0 { maxEntries = DefaultMaxEntries @@ -102,31 +115,59 @@ func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { maxBytes = DefaultMaxTotalUncompressed } - zr, err := newZipReader(data) + staging, err := os.MkdirTemp("", "azd-skill-extract-*") if err != nil { - return nil, err + return nil, fmt.Errorf("create staging directory: %w", err) + } + cleanupStaging := func() { _ = os.RemoveAll(staging) } + + var ( + files []string + totalBytes int64 + stageErr error + ) + switch DetectArchiveFormat(data) { + case ArchiveZip: + files, totalBytes, stageErr = stageFromZip(data, staging, maxEntries, maxBytes) + case ArchiveTarGz: + files, totalBytes, stageErr = stageFromTarGz(data, staging, maxEntries, maxBytes) + default: + cleanupStaging() + return nil, fmt.Errorf("%w: unrecognized magic bytes", ErrInvalidArchive) + } + if stageErr != nil { + cleanupStaging() + return nil, stageErr } - if len(zr.File) > maxEntries { - return nil, fmt.Errorf("%w: entry count %d exceeds %d", ErrLimitExceeded, len(zr.File), maxEntries) + if err := publishToOutputDir(staging, files, opts); err != nil { + cleanupStaging() + return nil, err } - staging, err := os.MkdirTemp("", "azd-skill-extract-*") + cleanupStaging() + return &ExtractResult{ + Files: files, + TotalBytes: totalBytes, + }, nil +} + +// stageFromZip extracts validated entries of a ZIP archive into staging. +func stageFromZip(data []byte, staging string, maxEntries int, maxBytes int64) ([]string, int64, error) { + zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) if err != nil { - return nil, fmt.Errorf("create staging directory: %w", err) + return nil, 0, fmt.Errorf("%w: %w", ErrInvalidArchive, err) } - cleanupStaging := func() { - _ = os.RemoveAll(staging) + if len(zr.File) > maxEntries { + return nil, 0, fmt.Errorf("%w: entry count %d exceeds %d", ErrLimitExceeded, len(zr.File), maxEntries) } var files []string var totalBytes int64 - for _, entry := range zr.File { cleaned, ok := validateEntryName(entry.Name) if !ok { - cleanupStaging() - return nil, fmt.Errorf("%w: %q", ErrUnsafeEntry, entry.Name) + return nil, 0, fmt.Errorf("%w: %q", ErrUnsafeEntry, entry.Name) } mode := entry.Mode() @@ -134,192 +175,191 @@ func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { case mode.IsDir() || strings.HasSuffix(entry.Name, "/"): dirPath := filepath.Join(staging, filepath.FromSlash(cleaned)) if mkErr := os.MkdirAll(dirPath, 0700); mkErr != nil { - cleanupStaging() - return nil, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) + return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) } continue case mode.IsRegular(): - // Fall through. default: - cleanupStaging() - return nil, fmt.Errorf("%w: %q has irregular file mode %v", ErrUnsafeEntry, entry.Name, mode) + return nil, 0, fmt.Errorf("%w: %q has irregular file mode %v", ErrUnsafeEntry, entry.Name, mode) } - stagingPath := filepath.Join(staging, filepath.FromSlash(cleaned)) - if mkErr := os.MkdirAll(filepath.Dir(stagingPath), 0700); mkErr != nil { - cleanupStaging() - return nil, fmt.Errorf("create staging dir for %q: %w", cleaned, mkErr) + written, writeErr := writeStagingEntry(staging, cleaned, func(w io.Writer, limit int64) (int64, error) { + rc, openErr := entry.Open() + if openErr != nil { + return 0, fmt.Errorf("open zip entry %q: %w", cleaned, openErr) + } + defer rc.Close() + return io.Copy(w, io.LimitReader(rc, limit)) + }, maxBytes-totalBytes, int64(entry.UncompressedSize64)) //nolint:gosec // bound-checked inside writeStagingEntry + if writeErr != nil { + return nil, 0, writeErr } + totalBytes += written + files = append(files, cleaned) + } + return files, totalBytes, nil +} + +// stageFromTarGz extracts validated entries of a gzip+tar archive into staging. +func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) ([]string, int64, error) { + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, 0, fmt.Errorf("%w: %w", ErrInvalidArchive, err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + var files []string + var totalBytes int64 + entryCount := 0 - remaining := maxBytes - totalBytes - // entry.UncompressedSize64 is advisory; we cap the actual reader so a - // lying header cannot exhaust disk. - if entry.UncompressedSize64 > 0 && int64(entry.UncompressedSize64) > remaining { //nolint:gosec // bound-checked just above - cleanupStaging() - return nil, fmt.Errorf("%w: uncompressed size would exceed %d bytes", ErrLimitExceeded, maxBytes) + for { + hdr, hdrErr := tr.Next() + if errors.Is(hdrErr, io.EOF) { + break + } + if hdrErr != nil { + return nil, 0, fmt.Errorf("read tar entry: %w", hdrErr) + } + entryCount++ + if entryCount > maxEntries { + return nil, 0, fmt.Errorf("%w: entry count exceeds %d", ErrLimitExceeded, maxEntries) } - rc, openErr := entry.Open() - if openErr != nil { - cleanupStaging() - return nil, fmt.Errorf("open zip entry %q: %w", cleaned, openErr) + cleaned, ok := validateEntryName(hdr.Name) + if !ok { + return nil, 0, fmt.Errorf("%w: %q", ErrUnsafeEntry, hdr.Name) } - f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // stagingPath is inside our trusted staging dir - if fErr != nil { - _ = rc.Close() - cleanupStaging() - return nil, fmt.Errorf("create staging file %q: %w", cleaned, fErr) + switch hdr.Typeflag { + case tar.TypeReg, tar.TypeRegA: + case tar.TypeDir: + dirPath := filepath.Join(staging, filepath.FromSlash(cleaned)) + if mkErr := os.MkdirAll(dirPath, 0700); mkErr != nil { + return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) + } + continue + default: + return nil, 0, fmt.Errorf("%w: %q has unsupported tar type %c", ErrUnsafeEntry, hdr.Name, hdr.Typeflag) } - // Cap reading at remaining+1: if we copy more than `remaining` the - // limit was violated. - written, copyErr := io.Copy(f, io.LimitReader(rc, remaining+1)) - closeRcErr := rc.Close() - closeFErr := f.Close() - switch { - case copyErr != nil: - cleanupStaging() - return nil, fmt.Errorf("write %q: %w", cleaned, copyErr) - case closeRcErr != nil: - cleanupStaging() - return nil, fmt.Errorf("close zip entry %q: %w", cleaned, closeRcErr) - case closeFErr != nil: - cleanupStaging() - return nil, fmt.Errorf("close staging file %q: %w", cleaned, closeFErr) - case written > remaining: - cleanupStaging() - return nil, fmt.Errorf("%w: uncompressed size would exceed %d bytes", ErrLimitExceeded, maxBytes) + written, writeErr := writeStagingEntry(staging, cleaned, func(w io.Writer, limit int64) (int64, error) { + return io.Copy(w, io.LimitReader(tr, limit)) + }, maxBytes-totalBytes, hdr.Size) + if writeErr != nil { + return nil, 0, writeErr } totalBytes += written files = append(files, cleaned) } + return files, totalBytes, nil +} - // All entries validated and written to staging. Check for collisions in - // OutputDir before any final copy so a partial copy never leaves files behind. +// writeStagingEntry creates parent directories and writes a single file +// entry into staging via writeBody. writeBody must copy at most `limit+1` +// bytes from its source; if it writes more than `remaining`, ErrLimitExceeded +// is returned. +func writeStagingEntry( + staging, relName string, + writeBody func(w io.Writer, limit int64) (int64, error), + remaining int64, + advertisedSize int64, +) (int64, error) { + stagingPath := filepath.Join(staging, filepath.FromSlash(relName)) + if mkErr := os.MkdirAll(filepath.Dir(stagingPath), 0700); mkErr != nil { + return 0, fmt.Errorf("create staging dir for %q: %w", relName, mkErr) + } + if advertisedSize > 0 && advertisedSize > remaining { + return 0, fmt.Errorf("%w: uncompressed size would exceed budget", ErrLimitExceeded) + } + f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // stagingPath is inside our trusted staging dir + if fErr != nil { + return 0, fmt.Errorf("create staging file %q: %w", relName, fErr) + } + written, copyErr := writeBody(f, remaining+1) + closeErr := f.Close() + switch { + case copyErr != nil: + return 0, fmt.Errorf("write %q: %w", relName, copyErr) + case closeErr != nil: + return 0, fmt.Errorf("close staging file %q: %w", relName, closeErr) + case written > remaining: + return 0, fmt.Errorf("%w: uncompressed size would exceed budget", ErrLimitExceeded) + } + return written, nil +} + +// publishToOutputDir checks for collisions, verifies no destination escapes +// OutputDir via symlinks, then copies every staged file into OutputDir. +func publishToOutputDir(staging string, files []string, opts ExtractOptions) error { if mkErr := os.MkdirAll(opts.OutputDir, 0700); mkErr != nil { - cleanupStaging() - return nil, fmt.Errorf("create output dir: %w", mkErr) + return fmt.Errorf("create output dir: %w", mkErr) } if !opts.Force { for _, rel := range files { dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) if _, statErr := os.Lstat(dst); statErr == nil { - cleanupStaging() - return nil, fmt.Errorf("%w: %s already exists in %s", ErrCollision, rel, opts.OutputDir) + return fmt.Errorf("%w: %s already exists in %s", ErrCollision, rel, opts.OutputDir) } else if !errors.Is(statErr, os.ErrNotExist) { - cleanupStaging() - return nil, fmt.Errorf("stat %q: %w", dst, statErr) + return fmt.Errorf("stat %q: %w", dst, statErr) } } } - // Resolve the real output directory path once so we can detect symlink - // escapes: if opts.OutputDir already contains a subdirectory that is a - // symlink pointing outside opts.OutputDir, an archive entry whose cleaned - // path starts with that component would silently write outside the - // intended destination. realOutDir, evalErr := filepath.EvalSymlinks(opts.OutputDir) if evalErr != nil { - cleanupStaging() - return nil, fmt.Errorf("resolve output dir path: %w", evalErr) + return fmt.Errorf("resolve output dir path: %w", evalErr) } - // Preflight pass: create destination subdirectories and verify that every - // resolved destination path stays inside opts.OutputDir before any file - // data is copied. This preserves the documented contract that a partial - // failure leaves no files behind: if any entry would escape via a symlink, - // we abort here without having copied anything yet. for _, rel := range files { dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) dstDir := filepath.Dir(dst) if mkErr := os.MkdirAll(dstDir, 0700); mkErr != nil { - cleanupStaging() - return nil, fmt.Errorf("create output dir for %q: %w", rel, mkErr) + return fmt.Errorf("create output dir for %q: %w", rel, mkErr) } realDstDir, evalErr := filepath.EvalSymlinks(dstDir) if evalErr != nil { - cleanupStaging() - return nil, fmt.Errorf("resolve destination path for %q: %w", rel, evalErr) + return fmt.Errorf("resolve destination path for %q: %w", rel, evalErr) } if !isUnder(realDstDir, realOutDir) { - cleanupStaging() - return nil, fmt.Errorf( + return fmt.Errorf( "%w: %q destination escapes output directory via symlink", ErrUnsafeEntry, rel, ) } } - // All destinations validated. Copy from staging to OutputDir. for _, rel := range files { src := filepath.Join(staging, filepath.FromSlash(rel)) dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) if err := copyFile(src, dst); err != nil { - cleanupStaging() - return nil, fmt.Errorf("copy %q to output: %w", rel, err) + return fmt.Errorf("copy %q to output: %w", rel, err) } } - - cleanupStaging() - return &ExtractResult{ - Files: files, - TotalBytes: totalBytes, - }, nil -} - -// newZipReader builds an in-memory zip.Reader from data. Returns ErrInvalidZip -// wrapped in any underlying parse error. -func newZipReader(data []byte) (*zip.Reader, error) { - if len(data) == 0 { - return nil, fmt.Errorf("%w: empty body", ErrInvalidZip) - } - zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrInvalidZip, err) - } - return zr, nil + return nil } -// validateEntryName cleans and validates a zip entry name. It returns the -// cleaned, slash-rooted relative path on success (no leading slash, no `..` -// segments). -// -// `..` is rejected even when surrounding segments cancel it out (e.g. -// `a/../b`). Cleaning would collapse that to `b`, which is technically safe -// from zip-slip, but the design spec rejects any `..` segment defensively -// so a future bug in `path.Clean` cannot regress us. func validateEntryName(name string) (string, bool) { if name == "" { return "", false } - // Zip entry names use forward slashes. Normalize to slashes before cleaning - // so we behave the same on Windows and Unix. slashed := strings.ReplaceAll(name, "\\", "/") - // Strip a single trailing slash for directory entries — keep the form for - // detection but don't fail validation on the slash itself. withoutTrailing := strings.TrimSuffix(slashed, "/") if withoutTrailing == "" { return "", false } - // Reject Windows drive-letter syntax masquerading as a relative path. if len(withoutTrailing) >= 2 && withoutTrailing[1] == ':' { return "", false } - // Reject absolute paths. if strings.HasPrefix(withoutTrailing, "/") { return "", false } - // Reject any `..` segment in the *raw* name. This is stricter than - // path.Clean — we want to refuse archives that even attempt traversal. for _, part := range strings.Split(withoutTrailing, "/") { if part == ".." { return "", false } } - // path.Clean to remove redundant separators and resolve `.` segments - // without traversing the filesystem. cleaned := path.Clean(withoutTrailing) if cleaned == "" || cleaned == "." || cleaned == "/" { return "", false @@ -330,8 +370,6 @@ func validateEntryName(name string) (string, bool) { return cleaned, true } -// isUnder reports whether child is the same as or nested within parent. -// Both paths must be cleaned real paths (output of filepath.EvalSymlinks). func isUnder(child, parent string) bool { if child == parent { return true @@ -356,9 +394,6 @@ func copyFile(src, dst string) error { return out.Close() } -// bytesReaderAt is a tiny io.ReaderAt over a byte slice. We avoid pulling in -// bytes.Reader so callers can pass slices that may grow without re-allocating -// the wrapper (zip.NewReader only needs ReadAt + length). type bytesReaderAt struct { data []byte } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go index a5f03f1ca3a..3772a05dc7f 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -4,6 +4,8 @@ package skill_api import ( + "archive/zip" + "fmt" "io" "path" "strings" @@ -36,13 +38,12 @@ const peekMaxEntries = 1024 // destructive `--force` guard only fires when the archive makes a name claim // that disagrees with the positional argument. // -// PeekArchiveSkillName never writes to disk and reads at most peekMaxSkillMdBytes -// bytes from any single entry. It returns an error only for unrecoverable -// stream problems (invalid zip). +// PeekArchiveSkillName accepts only ZIP archives — the upload surface is +// ZIP-only, so this is the only format relevant to the `--force` guard. func PeekArchiveSkillName(data []byte) (string, error) { - zr, err := newZipReader(data) + zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) if err != nil { - return "", err + return "", fmt.Errorf("%w: %w", ErrInvalidArchive, err) } for i, entry := range zr.File { diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go index c1f171b4bfe..7bd962d0ba0 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -4,8 +4,10 @@ package skill_api import ( + "archive/tar" "archive/zip" "bytes" + "compress/gzip" "errors" "os" "path/filepath" @@ -52,6 +54,29 @@ func makeZip(t *testing.T, entries []zipEntry) []byte { return buf.Bytes() } +// tarEntry describes a tar entry for test archives. +type tarEntry struct { + Name string + Body []byte +} + +// makeTarGz builds an in-memory gzip-compressed tar archive. +func makeTarGz(t *testing.T, entries []tarEntry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + hdr := &tar.Header{Name: e.Name, Mode: 0644, Typeflag: tar.TypeReg, Size: int64(len(e.Body))} + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(e.Body) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + func TestSafeExtract_HappyPath(t *testing.T) { archive := makeZip(t, []zipEntry{ {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, @@ -154,16 +179,38 @@ func TestSafeExtract_ForceOverwrites(t *testing.T) { require.Equal(t, "new", string(got)) } -func TestSafeExtract_InvalidZip(t *testing.T) { +func TestSafeExtract_InvalidArchive(t *testing.T) { _, err := SafeExtract([]byte("not a zip stream"), ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) - require.True(t, errors.Is(err, ErrInvalidZip)) + require.True(t, errors.Is(err, ErrInvalidArchive)) } func TestSafeExtract_EmptyBody(t *testing.T) { _, err := SafeExtract(nil, ExtractOptions{OutputDir: t.TempDir()}) require.Error(t, err) - require.True(t, errors.Is(err, ErrInvalidZip)) + require.True(t, errors.Is(err, ErrInvalidArchive)) +} + +func TestSafeExtract_TarGzHappyPath(t *testing.T) { + archive := makeTarGz(t, []tarEntry{ + {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, + {Name: "assets/icon.svg", Body: []byte("")}, + }) + dir := t.TempDir() + res, err := SafeExtract(archive, ExtractOptions{OutputDir: dir}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"SKILL.md", "assets/icon.svg"}, res.Files) +} + +func TestDetectArchiveFormat(t *testing.T) { + zipBytes := makeZip(t, []zipEntry{{Name: "SKILL.md", Body: []byte("body")}}) + require.Equal(t, ArchiveZip, DetectArchiveFormat(zipBytes)) + + tarGzBytes := makeTarGz(t, []tarEntry{{Name: "SKILL.md", Body: []byte("body")}}) + require.Equal(t, ArchiveTarGz, DetectArchiveFormat(tarGzBytes)) + + require.Equal(t, ArchiveUnknown, DetectArchiveFormat([]byte("not an archive"))) + require.Equal(t, ArchiveUnknown, DetectArchiveFormat(nil)) } func TestValidateEntryName(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go index 70312c05dad..b55d3d08abd 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -37,12 +37,15 @@ const ( // ContentTypeJSON is the request/response content type used for the JSON // surface. ContentTypeJSON = "application/json" - // ContentTypeZip is the content type used by POST /skills:import (request) - // and by GET /skills/{name}:download (response). The TypeSpec describes - // the body as `bytes` with `Content-Type: application/gzip`, but the live - // service implements ZIP per the public docs — verified via 415 on gzip. + // ContentTypeZip is the upload content type for POST /skills:import. The + // TypeSpec declares `application/gzip`, but the live service returns + // 415 Unsupported Media Type on gzip and accepts ZIP per the public docs. // See https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills. ContentTypeZip = "application/zip" + // ContentTypeGzip is the response content type observed on + // GET /skills/{name}:download. The same TypeSpec / docs mismatch applies + // in reverse: docs say zip, server returns gzip. We accept either. + ContentTypeGzip = "application/gzip" // BearerScope is the Azure AD scope used for the bearer-token policy. // Matches the scope used by the rest of the Foundry AI extension surface. @@ -312,18 +315,22 @@ func (c *Client) ListAll(ctx context.Context, opts ListOptions, limit int) ([]Sk } } -// Download fetches the ZIP archive for a skill. Returns the raw bytes for -// in-memory processing — zip needs an io.ReaderAt so streaming the body -// directly into archive/zip is not possible without buffering anyway. -// The Content-Type header is validated to start with `application/zip`. +// Download fetches the skill package and returns the raw bytes. +// +// The Foundry Skills service is asymmetric about archive format: uploads +// (`POST /skills:import`) reject `application/gzip` with 415 and require +// `application/zip`, but downloads return `application/gzip`. Rather than +// pin a single Content-Type, this client accepts either and leaves format +// detection (via magic bytes) to the caller — see DetectArchiveFormat. func (c *Client) Download(ctx context.Context, name string) ([]byte, error) { httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, ":download", nil)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } addStandardHeaders(httpReq) - // Override the JSON Accept default with the archive content type. - httpReq.Raw().Header.Set("Accept", ContentTypeZip) + // Accept both formats; service ignores the value but the negotiation + // matters for intermediaries that might transform the body. + httpReq.Raw().Header.Set("Accept", ContentTypeZip+", "+ContentTypeGzip) resp, err := c.pipeline.Do(httpReq) if err != nil { @@ -335,8 +342,11 @@ func (c *Client) Download(ctx context.Context, name string) ([]byte, error) { return nil, runtime.NewResponseError(resp) } - if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(strings.ToLower(ct), ContentTypeZip) { - return nil, fmt.Errorf("unexpected download content type %q (want %s)", ct, ContentTypeZip) + if ct := resp.Header.Get("Content-Type"); ct != "" { + lc := strings.ToLower(ct) + if !strings.HasPrefix(lc, ContentTypeZip) && !strings.HasPrefix(lc, ContentTypeGzip) { + return nil, fmt.Errorf("unexpected download content type %q (want %s or %s)", ct, ContentTypeZip, ContentTypeGzip) + } } body, err := io.ReadAll(resp.Body) From 16a3cb35f37a4dc249eeb1c752a876dca281ef54 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 18 May 2026 17:07:44 +0800 Subject: [PATCH 07/13] refactor(skills): trim unnecessary comments and doc blocks Remove restating-the-code comments, narrative section headers, and verbose struct-field docs. Keep design-rationale notes (TypeSpec/docs mismatch on archive format, --force destructive sequence, defensive path validation) and important safety annotations. Net: -396 lines of comments, no behavior change. --- cli/azd/extensions/azure.ai.skills/AGENTS.md | 2 +- .../azure.ai.skills/internal/cmd/debug.go | 27 +--- .../azure.ai.skills/internal/cmd/endpoint.go | 40 +----- .../azure.ai.skills/internal/cmd/output.go | 5 - .../azure.ai.skills/internal/cmd/root.go | 22 +--- .../internal/cmd/skill_context.go | 12 +- .../internal/cmd/skill_create.go | 122 ++++++------------ .../internal/cmd/skill_delete.go | 29 +---- .../internal/cmd/skill_download.go | 86 ++++-------- .../internal/cmd/skill_list.go | 25 +--- .../internal/cmd/skill_show.go | 15 +-- .../internal/cmd/skill_update.go | 35 ++--- .../internal/cmd/skill_validate.go | 14 +- .../azure.ai.skills/internal/cmd/version.go | 2 - .../internal/exterrors/codes.go | 31 ++--- .../internal/pkg/skill_api/archive.go | 91 +++++-------- .../internal/pkg/skill_api/archive_peek.go | 55 ++------ .../internal/pkg/skill_api/archive_test.go | 9 +- .../internal/pkg/skill_api/client.go | 84 +++--------- .../internal/pkg/skill_api/client_test.go | 7 +- .../internal/pkg/skill_api/models.go | 53 +++----- .../internal/pkg/skill_api/skill_md.go | 80 ++++-------- .../internal/version/version.go | 4 +- 23 files changed, 227 insertions(+), 623 deletions(-) diff --git a/cli/azd/extensions/azure.ai.skills/AGENTS.md b/cli/azd/extensions/azure.ai.skills/AGENTS.md index 6539feadfbb..bb01bd4e24f 100644 --- a/cli/azd/extensions/azure.ai.skills/AGENTS.md +++ b/cli/azd/extensions/azure.ai.skills/AGENTS.md @@ -54,7 +54,7 @@ Skill-specific error codes live in `internal/exterrors/codes.go`: - `CodeInvalidSkillName` — name fails the alphanumeric-with-hyphens regex - `CodeInvalidSkillFile` — SKILL.md front matter unparsable, or `--file` extension unsupported -- `CodeSkillArchiveUnsafe` — `download` rejected a tar entry (zip-slip, symlink, oversized, etc.) +- `CodeSkillArchiveUnsafe` — `download` rejected an archive entry (zip-slip, symlink, oversized, etc.) - `CodeSkillOutputCollision` — `download` would overwrite an existing file without `--force` ## Debug logging diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go index 522216332ff..46045b9d233 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go @@ -15,22 +15,10 @@ import ( "github.com/spf13/pflag" ) -// setupDebugLogging configures debug logging for the extension. -// -// When debug mode is disabled, the standard Go logger writes to io.Discard and -// the Azure SDK logger is silenced. When enabled, both write into -// `azd-ai-skills-.log` in the current working directory (or stderr if -// the file cannot be opened). -// -// The Azure SDK pipeline is configured with `Logging.IncludeBody: false` for -// every skill request — see `skill_api/client.go` and §11 of the design spec -// for the rationale (request bodies carry user-authored description / -// instructions, and there is no sanitizer in place yet). -// -// Returns a cleanup function that should be deferred by the caller. The -// extension currently discards the cleanup because log writes are unbuffered -// and the OS closes the file on exit; the cleanup is provided so callers that -// care can deterministically close the file. +// setupDebugLogging routes the standard logger and the Azure SDK logger to a +// per-day file when --debug is set, and to io.Discard otherwise. The SDK +// pipeline runs with IncludeBody=false so request/response bodies (which can +// carry user-authored description / instructions) never reach the log. func setupDebugLogging(flags *pflag.FlagSet) func() { if !isDebug(flags) { log.SetOutput(io.Discard) @@ -38,8 +26,7 @@ func setupDebugLogging(flags *pflag.FlagSet) func() { return func() {} } - currentDate := time.Now().Format("2006-01-02") - logFileName := fmt.Sprintf("azd-ai-skills-%s.log", currentDate) + logFileName := fmt.Sprintf("azd-ai-skills-%s.log", time.Now().Format("2006-01-02")) //nolint:gosec // log file name is generated locally from date and not user-controlled logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) @@ -56,8 +43,6 @@ func setupDebugLogging(flags *pflag.FlagSet) func() { log.SetOutput(w) azcorelog.SetListener(func(event azcorelog.Event, msg string) { - // Body logging is disabled in the pipeline, so msg never contains - // request/response bodies. Even so, never log raw skill content. fmt.Fprintf(w, "[%s] %s: %s\n", time.Now().Format(time.RFC3339), event, msg) }) @@ -68,8 +53,6 @@ func setupDebugLogging(flags *pflag.FlagSet) func() { } } -// isDebug reports whether --debug is set on the command line or -// AZD_EXT_DEBUG is enabled in the environment. func isDebug(flags *pflag.FlagSet) bool { if flags != nil { if v, err := flags.GetBool("debug"); err == nil && v { diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go index 52a1b888d2a..c113cdc1167 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go @@ -16,8 +16,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -// endpointSource identifies where the resolved Foundry project endpoint came -// from. Used for telemetry and debug logging only — never echoed to the user. type endpointSource string const ( @@ -28,32 +26,16 @@ const ( ) const ( - // skillsContextEndpointKey is the global-config path this extension owns. skillsContextEndpointKey = "extensions.ai-skills.project.context.endpoint" - // agentsContextEndpointKey is the global-config path owned by - // `azure.ai.agents`. We read it as a fallback so users who configured the - // endpoint via the agents extension do not have to re-run `set`. + // Read-only fallback to the ai-agents key so users who configured the + // endpoint via that extension don't have to re-run `set`. agentsContextEndpointKey = "extensions.ai-agents.project.context.endpoint" - // azdEnvKey is the active azd environment value that supplies the - // Foundry project endpoint. - azdEnvKey = "AZURE_AI_PROJECT_ENDPOINT" - // foundryEnvKey is the host environment variable that supplies the - // Foundry project endpoint as a last-resort fallback. - foundryEnvKey = "FOUNDRY_PROJECT_ENDPOINT" + azdEnvKey = "AZURE_AI_PROJECT_ENDPOINT" + foundryEnvKey = "FOUNDRY_PROJECT_ENDPOINT" ) -// resolveProjectEndpoint implements the 5-level cascade from the design spec. -// -// 1. flagEndpoint (from -p / --project-endpoint). -// 2. Active azd env value AZURE_AI_PROJECT_ENDPOINT. -// 3. Global config extensions.ai-skills.project.context.endpoint, falling -// back to extensions.ai-agents.project.context.endpoint. -// 4. Host env var FOUNDRY_PROJECT_ENDPOINT. -// 5. Structured error. -// -// Each resolved value is validated to be an absolute https:// URL. -// Host-suffix validation is intentionally deferred to the HTTP layer — the -// skills data plane accepts any reachable HTTPS Foundry endpoint. +// resolveProjectEndpoint implements the 5-level cascade from the design spec: +// flag, azd env, global config (skills then agents), host env, error. func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, endpointSource, error) { if flagEndpoint != "" { if err := validateEndpoint(flagEndpoint); err != nil { @@ -62,13 +44,9 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e return flagEndpoint, sourceFlag, nil } - // Levels 2 & 3 require the azd client. If azd is not running this - // extension as a child process (unlikely in practice), skip both and fall - // through to the host env var. if azdClient, err := azdext.NewAzdClient(); err == nil { defer azdClient.Close() - // 2. Active azd env value. if envResp, envErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); envErr == nil { if valResp, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ EnvName: envResp.Environment.Name, @@ -81,7 +59,6 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e } } - // 3. Global config: skills key first, then agents fallback. if ch, chErr := azdext.NewConfigHelper(azdClient); chErr == nil { for _, key := range []string{skillsContextEndpointKey, agentsContextEndpointKey} { var endpoint string @@ -98,7 +75,6 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e } } - // 4. Host env var. if ep := os.Getenv(foundryEnvKey); ep != "" { if err := validateEndpoint(ep); err != nil { return "", "", err @@ -106,7 +82,6 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e return ep, sourceFoundryEnv, nil } - // 5. Structured error. return "", "", exterrors.Dependency( exterrors.CodeMissingProjectEndpoint, "no Foundry project endpoint resolved", @@ -116,9 +91,6 @@ func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, e ) } -// validateEndpoint returns an error when the endpoint is not an absolute -// https:// URL. Host-suffix validation is intentionally deferred to the -// SDK layer; the skills surface accepts any reachable HTTPS Foundry endpoint. func validateEndpoint(endpoint string) error { u, err := url.Parse(endpoint) if err != nil { diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go index cfce2c1b985..c62b7cf71d7 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go @@ -19,8 +19,6 @@ const ( outputTable = "table" ) -// printJSON marshals v to indented JSON on stdout. Returns any marshaling -// error so the caller can surface it to the user. func printJSON(v any) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -28,8 +26,6 @@ func printJSON(v any) error { return enc.Encode(v) } -// printSkillDetail renders one skill in either JSON (passthrough) or table -// (key/value) form. func printSkillDetail(s *skill_api.Skill, format string) error { if format == outputJSON { return printJSON(s) @@ -53,7 +49,6 @@ func printSkillDetail(s *skill_api.Skill, format string) error { return nil } -// printSkillList renders a flat slice of skills. func printSkillList(items []skill_api.Skill, format string) error { if format == outputJSON { if items == nil { diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go index 68c43ab0924..6678f0e3497 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go @@ -11,13 +11,6 @@ import ( "github.com/spf13/cobra" ) -// NewRootCommand builds the `azd ai skill` root command and its subcommand -// graph. The cobra root is constructed by [azdext.NewExtensionRootCommand] -// so that azd's global flags (`--debug`, `--no-prompt`, `--cwd`, -// `-e/--environment`, `--output`) are pre-registered. -// -// The cobra `Name` is `skill`. The extension's namespace `ai.skill` maps it -// under the existing `azd ai` group at install time. func NewRootCommand() *cobra.Command { rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ Name: "skill", @@ -35,9 +28,6 @@ a Foundry project.`, rootCmd.SilenceErrors = true rootCmd.CompletionOptions.DisableDefaultCmd = true - // Configure debug logging once on the root command so every subcommand - // inherits it. The cleanup func is intentionally discarded: log writes - // are unbuffered and the OS closes the file on exit. sdkPreRun := rootCmd.PersistentPreRunE rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if sdkPreRun != nil { @@ -51,19 +41,15 @@ a Foundry project.`, rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - // Register -p / --project-endpoint as a persistent flag so all subcommands - // inherit it without redeclaring. rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", "Foundry project endpoint URL (overrides env vars and global config)") - // Standard extension commands. rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.skills", func() *cobra.Command { return rootCmd })) - // Skill subcommands. rootCmd.AddCommand(newCreateCommand(extCtx)) rootCmd.AddCommand(newUpdateCommand(extCtx)) rootCmd.AddCommand(newShowCommand(extCtx)) @@ -74,8 +60,6 @@ a Foundry project.`, return rootCmd } -// configureExtensionHost is the `listen` configuration callback. Skills do not -// need to register any lifecycle hooks, so the callback is a no-op. -func configureExtensionHost(host *azdext.ExtensionHost) { - _ = host -} +// configureExtensionHost is the listen callback. Skills register no +// lifecycle hooks, so it's a no-op. +func configureExtensionHost(host *azdext.ExtensionHost) { _ = host } diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go index 9bbfd16d4d6..5b9fbde3a43 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go @@ -15,38 +15,28 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" ) -// skillContext bundles the resolved REST client + endpoint information for a -// single command invocation. All actions resolve this once at the top of -// their Run() method. type skillContext struct { client *skill_api.Client endpoint string source endpointSource } -// resolveSkillContext resolves the project endpoint via the standard cascade -// and creates a Foundry Skills REST client. func resolveSkillContext(ctx context.Context, flagEndpoint string) (*skillContext, error) { endpoint, src, err := resolveProjectEndpoint(ctx, flagEndpoint) if err != nil { return nil, err } - cred, err := newCredential() if err != nil { return nil, err } - - client := skill_api.NewClient(endpoint, cred, version.Version) return &skillContext{ - client: client, + client: skill_api.NewClient(endpoint, cred, version.Version), endpoint: endpoint, source: src, }, nil } -// newCredential creates the Azure credential used by every skill API call. -// Uses the azd-managed `azd auth login` token when available. func newCredential() (azcore.TokenCredential, error) { cred, err := azidentity.NewAzureDeveloperCLICredential( &azidentity.AzureDeveloperCLICredentialOptions{}, diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go index a6ac683ed04..ec7d941419e 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -19,7 +19,6 @@ import ( "github.com/spf13/cobra" ) -// createFlags holds parsed input for the `skill create` command. type createFlags struct { name string description string @@ -34,12 +33,8 @@ type createFlags struct { instructionsSet bool } -// createAction is the create-command implementation. -type createAction struct { - flags *createFlags -} +type createAction struct{ flags *createFlags } -// Run executes the create operation. func (a *createAction) Run(ctx context.Context) error { if err := validateSkillName(a.flags.name); err != nil { return err @@ -50,9 +45,6 @@ func (a *createAction) Run(ctx context.Context) error { return err } - // Resolve interactive prompts (mode==modeNone with prompting available) - // before doing any IO so the user sees a fast validation error if neither - // inline nor file mode was supplied with --no-prompt. if mode == modeNone { if a.flags.noPrompt { return exterrors.Validation( @@ -72,26 +64,18 @@ func (a *createAction) Run(ctx context.Context) error { return err } - // In package mode, --force is destructive: it deletes the existing skill - // by positional name before we have any guarantee that the archive being - // uploaded targets the same skill. Peek into the archive's SKILL.md and - // verify the embedded `name` matches the positional argument *before* - // any destructive call. If the archive omits `name`, we accept it (the - // positional argument wins, matching inline/file-md behavior). + // --force in package mode is destructive: we delete the existing skill by + // positional name before any guarantee the archive targets the same one. + // Peek the embedded SKILL.md `name` and bail if it disagrees. if mode == modeFilePackage && a.flags.force { if err := verifyPackageNameMatches(a.flags.file, a.flags.name); err != nil { return err } } - // Honor --force by deleting the existing skill first. if a.flags.force { - if _, delErr := skillCtx.client.Delete(ctx, a.flags.name); delErr != nil { - // Only swallow 404 — anything else means the upcoming create would - // likely fail too, so surface it now. - if !isNotFound(delErr) { - return exterrors.ServiceFromAzure(delErr, exterrors.OpDeleteSkill) - } + if _, delErr := skillCtx.client.Delete(ctx, a.flags.name); delErr != nil && !isNotFound(delErr) { + return exterrors.ServiceFromAzure(delErr, exterrors.OpDeleteSkill) } } @@ -158,13 +142,12 @@ func (a *createAction) runFileMd(ctx context.Context, client *skill_api.Client) ) } - req := skill_api.CreateRequest{ + created, err := client.CreateInline(ctx, skill_api.CreateRequest{ Name: a.flags.name, Description: parsed.Description, Instructions: parsed.Instructions, Metadata: parsed.Metadata, - } - created, err := client.CreateInline(ctx, req) + }) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) } @@ -188,7 +171,7 @@ func (a *createAction) runFilePackage(ctx context.Context, client *skill_api.Cli ) } - f, openErr := os.Open(a.flags.file) //nolint:gosec // path supplied by the user, opened for the user + f, openErr := os.Open(a.flags.file) //nolint:gosec // user-supplied path opened on user's behalf if openErr != nil { return exterrors.Validation( exterrors.CodeInvalidSkillFile, @@ -213,19 +196,12 @@ func printCreateResult(s *skill_api.Skill, format string) error { return printSkillDetail(s, outputTable) } -// verifyPackageNameMatches peeks SKILL.md from a .zip archive and ensures -// its front-matter `name` (when present) matches the positional argument the -// user supplied. This guards `--force` in package mode against the -// destructive sequence "delete positional name, then upload archive that -// targets a different skill": without this check, a typo in the positional -// argument can wipe out an unrelated skill before the import is even -// attempted. -// -// Returns nil when the archive's SKILL.md is missing a `name` field (legacy -// archives), or when the embedded name matches positionalName. Returns a -// structured validation error otherwise. +// verifyPackageNameMatches refuses --force when the archive's SKILL.md +// declares a different `name` than the positional argument, to avoid +// wiping an unrelated skill on a typo. Returns nil when the archive omits +// `name` (no claim) or when the names agree. func verifyPackageNameMatches(archivePath, positionalName string) error { - data, readErr := os.ReadFile(archivePath) //nolint:gosec // user-supplied path read on their behalf + data, readErr := os.ReadFile(archivePath) //nolint:gosec // user-supplied path read on user's behalf if readErr != nil { return exterrors.Validation( exterrors.CodeInvalidSkillFile, @@ -233,7 +209,6 @@ func verifyPackageNameMatches(archivePath, positionalName string) error { "verify the file is readable", ) } - archiveName, peekErr := skill_api.PeekArchiveSkillName(data) if peekErr != nil { return exterrors.Validation( @@ -247,16 +222,11 @@ func verifyPackageNameMatches(archivePath, positionalName string) error { } return exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf( - "--force refused: archive declares name %q which does not match positional argument %q", - archiveName, positionalName, - ), + fmt.Sprintf("--force refused: archive declares name %q which does not match positional argument %q", archiveName, positionalName), "re-run without --force, or fix the positional name / archive so they agree", ) } -// --- mode selection --- - type createMode int const ( @@ -279,9 +249,7 @@ func selectCreateMode(f *createFlags) (createMode, error) { } if fileProvided { - ext := strings.ToLower(filepath.Ext(f.file)) - // `.zip` for packages; `.md` for SKILL.md inline body. - switch ext { + switch strings.ToLower(filepath.Ext(f.file)) { case ".md": return modeFileMd, nil case ".zip": @@ -289,7 +257,7 @@ func selectCreateMode(f *createFlags) (createMode, error) { default: return modeNone, exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf("unsupported --file extension %q", ext), + fmt.Sprintf("unsupported --file extension %q", filepath.Ext(f.file)), "use .md for inline metadata or .zip for a package upload", ) } @@ -313,41 +281,37 @@ func promptForInline(ctx context.Context, f *createFlags) error { defer azdClient.Close() if strings.TrimSpace(f.description) == "" { - resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ - Message: "Skill description", - HelpMessage: "A short human-readable summary of what this skill " + - "does. Sent to the service as `description`.", - Required: true, + Message: "Skill description", + HelpMessage: "Short human-readable summary. Sent to the service as `description`.", + Required: true, }, }) - if promptErr != nil { - return promptErr + if err != nil { + return err } f.description = resp.Value f.descriptionSet = true } if strings.TrimSpace(f.instructions) == "" { - resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ - Message: "Skill instructions (Markdown body)", - HelpMessage: "The Markdown body that defines the skill's " + - "behavior. Sent to the service as `instructions`.", - Required: true, + Message: "Skill instructions (Markdown body)", + HelpMessage: "Markdown body that defines the skill's behavior. Sent as `instructions`.", + Required: true, }, }) - if promptErr != nil { - return promptErr + if err != nil { + return err } f.instructions = resp.Value f.instructionsSet = true } - return nil } -// newCreateCommand constructs the `skill create` Cobra command. func newCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &createFlags{} action := &createAction{flags: flags} @@ -373,31 +337,22 @@ Pass --force to delete an existing skill of the same name before creating.`, flags.descriptionSet = cmd.Flags().Changed("description") flags.instructionsSet = cmd.Flags().Changed("instructions") flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") - - ctx := azdext.WithAccessToken(cmd.Context()) - return action.Run(ctx) + return action.Run(azdext.WithAccessToken(cmd.Context())) }, } - cmd.Flags().StringVar(&flags.description, "description", "", - "Inline mode: human-readable summary of the skill") - cmd.Flags().StringVar(&flags.instructions, "instructions", "", - "Inline mode: Markdown body defining skill behavior") - cmd.Flags().StringVar(&flags.file, "file", "", - "Path to SKILL.md (.md) or a ZIP package (.zip)") - cmd.Flags().BoolVar(&flags.force, "force", false, - "Delete an existing skill of the same name before creating") + cmd.Flags().StringVar(&flags.description, "description", "", "Inline mode: human-readable summary of the skill") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", "Inline mode: Markdown body defining skill behavior") + cmd.Flags().StringVar(&flags.file, "file", "", "Path to SKILL.md (.md) or a ZIP package (.zip)") + cmd.Flags().BoolVar(&flags.force, "force", false, "Delete an existing skill of the same name before creating") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, }) return cmd } -// --- shared helpers --- - -// readFileWithLimit reads up to 1 MiB from path. SKILL.md should be well under -// the service's 100 KiB+1 KiB description/instruction caps, so 1 MiB is a -// generous bound that still defends against accidentally reading a giant file. +// readFileWithLimit reads up to 1 MiB from path. SKILL.md is small in practice; +// the cap guards against reading a giant file by accident. func readFileWithLimit(path string) ([]byte, error) { info, err := os.Stat(path) if err != nil { @@ -422,7 +377,7 @@ func readFileWithLimit(path string) ([]byte, error) { "split the file into smaller assets and use a package upload", ) } - data, err := os.ReadFile(path) //nolint:gosec // user-supplied path read on their behalf + data, err := os.ReadFile(path) //nolint:gosec // user-supplied path read on user's behalf if err != nil { return nil, exterrors.Validation( exterrors.CodeInvalidSkillFile, @@ -433,13 +388,10 @@ func readFileWithLimit(path string) ([]byte, error) { return data, nil } -// shouldSuppressWarning reports whether interactive warnings (e.g., front -// matter name mismatch) should be suppressed. func shouldSuppressWarning(noPrompt bool, format string) bool { return noPrompt || format == outputJSON } -// isNotFound reports whether err looks like an HTTP 404 from the service. func isNotFound(err error) bool { var respErr *azcore.ResponseError if errors.As(err, &respErr) { diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go index dde68399b01..0bd5ab925dd 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go @@ -13,7 +13,6 @@ import ( "github.com/spf13/cobra" ) -// deleteFlags holds parsed input for the `skill delete` command. type deleteFlags struct { name string force bool @@ -22,20 +21,15 @@ type deleteFlags struct { projectEndpoint string } -// deleteAction is the delete-command implementation. -type deleteAction struct { - flags *deleteFlags -} +type deleteAction struct{ flags *deleteFlags } -// deleteResult is the JSON shape printed when --output=json. Cancelled -// deletions are represented as `{ deleted: false, cancelled: true, name }`. +// deleteResult is the JSON shape printed when --output=json. type deleteResult struct { Name string `json:"name"` Deleted bool `json:"deleted"` Cancelled bool `json:"cancelled,omitempty"` } -// Run executes the delete operation. func (a *deleteAction) Run(ctx context.Context) error { if err := validateSkillName(a.flags.name); err != nil { return err @@ -49,17 +43,12 @@ func (a *deleteAction) Run(ctx context.Context) error { "pass --force to skip confirmation in non-interactive mode", ) } - confirmed, err := a.confirmDelete(ctx) if err != nil { return err } if !confirmed { - return a.printResult(deleteResult{ - Name: a.flags.name, - Deleted: false, - Cancelled: true, - }) + return a.printResult(deleteResult{Name: a.flags.name, Cancelled: true}) } } @@ -67,18 +56,16 @@ func (a *deleteAction) Run(ctx context.Context) error { if err != nil { return err } - if _, err := skillCtx.client.Delete(ctx, a.flags.name); err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpDeleteSkill) } - return a.printResult(deleteResult{Name: a.flags.name, Deleted: true}) } func (a *deleteAction) confirmDelete(ctx context.Context) (bool, error) { azdClient, err := azdext.NewAzdClient() if err != nil { - return false, fmt.Errorf("failed to create azd client for confirmation: %w", err) + return false, fmt.Errorf("create azd client for confirmation: %w", err) } defer azdClient.Close() @@ -110,7 +97,6 @@ func (a *deleteAction) printResult(res deleteResult) error { return nil } -// newDeleteCommand constructs the `skill delete` Cobra command. func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &deleteFlags{} action := &deleteAction{flags: flags} @@ -128,14 +114,11 @@ In --no-prompt mode (set globally), --force is required.`, flags.output = extCtx.OutputFormat flags.noPrompt = extCtx.NoPrompt flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") - - ctx := azdext.WithAccessToken(cmd.Context()) - return action.Run(ctx) + return action.Run(azdext.WithAccessToken(cmd.Context())) }, } - cmd.Flags().BoolVar(&flags.force, "force", false, - "Skip the confirmation prompt") + cmd.Flags().BoolVar(&flags.force, "force", false, "Skip the confirmation prompt") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, }) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go index 5c6ae24b81f..c9fa4f13816 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -17,7 +17,6 @@ import ( "github.com/spf13/cobra" ) -// downloadFlags holds parsed input for the `skill download` command. type downloadFlags struct { name string outputDir string @@ -29,13 +28,9 @@ type downloadFlags struct { outputDirSet bool } -// downloadAction is the download-command implementation. -type downloadAction struct { - flags *downloadFlags -} +type downloadAction struct{ flags *downloadFlags } -// downloadResult is the JSON shape printed when --output=json. The shape is -// part of the published contract: callers depend on it. +// downloadResult is the JSON shape printed when --output=json. Public contract. type downloadResult struct { Skill string `json:"skill"` OutputDir string `json:"outputDir"` @@ -44,7 +39,6 @@ type downloadResult struct { Raw bool `json:"raw"` } -// Run executes the download operation. func (a *downloadAction) Run(ctx context.Context) error { if err := validateSkillName(a.flags.name); err != nil { return err @@ -68,11 +62,8 @@ func (a *downloadAction) Run(ctx context.Context) error { return err } - // Pre-flight via Get so we can detect the "no associated package" case - // before issuing the :download call. The service returns 404 with a - // dedicated error code when the skill was created from inline JSON (or a - // SKILL.md file) rather than a ZIP package, but the message is opaque — - // surfacing the HasBlob check up front gives the user a clearer answer. + // Pre-flight Get so we can give a clear error when the skill has no blob + // (created from JSON / SKILL.md). The server's 404 here is opaque. skill, err := skillCtx.client.Get(ctx, a.flags.name) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) @@ -104,15 +95,12 @@ func (a *downloadAction) writeRaw(body []byte, outputDir string) error { return fmt.Errorf("create output dir: %w", err) } - // Pick a file extension that matches the actual archive bytes the service - // returned. The Foundry surface is asymmetric (uploads ZIP, downloads - // gzip) so we can't assume one extension. - ext := archiveExtension(skill_api.DetectArchiveFormat(body)) - archiveName := a.flags.name + ext + // Pick the extension that matches the actual bytes (service is asymmetric). + archiveName := a.flags.name + archiveExtension(skill_api.DetectArchiveFormat(body)) archivePath := filepath.Join(outputDir, archiveName) // Always Lstat (even with --force) so we never follow a symlink and so we - // refuse to overwrite a non-regular file (directory, device, socket, ...). + // refuse to overwrite a non-regular file. if statInfo, statErr := os.Lstat(archivePath); statErr == nil { if statInfo.Mode()&os.ModeSymlink != 0 { return exterrors.Validation( @@ -135,10 +123,8 @@ func (a *downloadAction) writeRaw(body []byte, outputDir string) error { "pass --force to overwrite", ) } - // --force: remove the existing regular file so the subsequent O_EXCL - // open creates a fresh file owned by this process. This avoids any - // TOCTOU window where the path could be swapped for a symlink between - // the Lstat and the open. + // Remove first so the subsequent O_EXCL open is atomic — closes the + // TOCTOU window where the path could be swapped for a symlink. if rmErr := os.Remove(archivePath); rmErr != nil { return fmt.Errorf("remove existing archive: %w", rmErr) } @@ -146,9 +132,6 @@ func (a *downloadAction) writeRaw(body []byte, outputDir string) error { return fmt.Errorf("stat %s: %w", archivePath, statErr) } - // O_EXCL guarantees we create the file ourselves; if anything appeared - // at archivePath in the meantime (e.g. a freshly-planted symlink), the - // open fails rather than silently following it. //nolint:gosec // archivePath is built from user-supplied --output-dir + skill name, written on user behalf f, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) if err != nil { @@ -162,18 +145,13 @@ func (a *downloadAction) writeRaw(body []byte, outputDir string) error { return fmt.Errorf("close archive: %w", err) } - res := downloadResult{ - Skill: a.flags.name, - OutputDir: outputDir, - Archive: archiveName, - Raw: true, - } - return a.printResult(res) + return a.printResult(downloadResult{ + Skill: a.flags.name, OutputDir: outputDir, Archive: archiveName, Raw: true, + }) } -// archiveExtension returns the on-disk file extension to use when writing a -// raw downloaded skill package. Falls back to ".bin" when the magic bytes -// don't match a known archive format so the file is still preserved. +// archiveExtension picks the on-disk extension based on the magic bytes the +// service returned. Falls back to .bin so the file is still preserved. func archiveExtension(format skill_api.ArchiveFormat) string { switch format { case skill_api.ArchiveZip: @@ -193,14 +171,9 @@ func (a *downloadAction) writeExtracted(body []byte, outputDir string) error { if err != nil { return classifyExtractError(err, outputDir) } - - res := downloadResult{ - Skill: a.flags.name, - OutputDir: outputDir, - Files: result.Files, - Raw: false, - } - return a.printResult(res) + return a.printResult(downloadResult{ + Skill: a.flags.name, OutputDir: outputDir, Files: result.Files, + }) } func (a *downloadAction) printResult(res downloadResult) error { @@ -218,9 +191,8 @@ func (a *downloadAction) printResult(res downloadResult) error { return nil } -// classifyExtractError converts a SafeExtract error into a structured -// extension error when possible. Unknown errors propagate unchanged so the -// gRPC layer surfaces them with a default Internal category. +// classifyExtractError wraps SafeExtract sentinels in structured extension +// errors. Unknown errors propagate as-is. func classifyExtractError(err error, outputDir string) error { switch { case errors.Is(err, skill_api.ErrUnsafeEntry): @@ -251,7 +223,6 @@ func classifyExtractError(err error, outputDir string) error { return err } -// newDownloadCommand constructs the `skill download` Cobra command. func newDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &downloadFlags{} action := &downloadAction{flags: flags} @@ -259,10 +230,10 @@ func newDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "download ", Short: "Download a Foundry skill package.", - Long: `Download a skill's ZIP package. + Long: `Download a skill's package. -By default the CLI extracts the archive into --output-dir (which defaults to -'./.agents/skills//'). Pass --raw to write the unmodified ZIP archive +By default the CLI extracts the archive into --output-dir (default +'./.agents/skills//'). Pass --raw to write the unmodified archive into --output-dir instead. Extraction enforces strict safety rules: no absolute paths, no '..' segments, @@ -274,18 +245,13 @@ total uncompressed size.`, flags.output = extCtx.OutputFormat flags.outputDirSet = cmd.Flags().Changed("output-dir") flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") - - ctx := azdext.WithAccessToken(cmd.Context()) - return action.Run(ctx) + return action.Run(azdext.WithAccessToken(cmd.Context())) }, } - cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", - "Directory to write the extracted skill (default: ./.agents/skills//)") - cmd.Flags().BoolVar(&flags.raw, "raw", false, - "Skip extraction; write the ZIP archive as-is to --output-dir") - cmd.Flags().BoolVar(&flags.force, "force", false, - "Overwrite existing files in --output-dir") + cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", "Directory to write the extracted skill (default: ./.agents/skills//)") + cmd.Flags().BoolVar(&flags.raw, "raw", false, "Skip extraction; write the archive as-is to --output-dir") + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite existing files in --output-dir") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, }) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go index 67891b07fdc..06f1badf9be 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go @@ -13,7 +13,6 @@ import ( "github.com/spf13/cobra" ) -// listFlags holds parsed input for the `skill list` command. type listFlags struct { top int orderBy string @@ -21,34 +20,24 @@ type listFlags struct { projectEndpoint string } -// listAction is the list-command implementation. -type listAction struct { - flags *listFlags -} +type listAction struct{ flags *listFlags } -// Run executes the list operation. func (a *listAction) Run(ctx context.Context) error { skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) if err != nil { return err } - items, err := skillCtx.client.ListAll( ctx, - skill_api.ListOptions{ - Top: a.flags.top, - OrderBy: a.flags.orderBy, - }, + skill_api.ListOptions{Top: a.flags.top, OrderBy: a.flags.orderBy}, a.flags.top, ) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpListSkills) } - return printSkillList(items, a.flags.output) } -// newListCommand constructs the `skill list` Cobra command. func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &listFlags{} action := &listAction{flags: flags} @@ -64,16 +53,12 @@ With --top, the CLI stops once that many items have been collected.`, RunE: func(cmd *cobra.Command, _ []string) error { flags.output = extCtx.OutputFormat flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") - - ctx := azdext.WithAccessToken(cmd.Context()) - return action.Run(ctx) + return action.Run(azdext.WithAccessToken(cmd.Context())) }, } - cmd.Flags().IntVar(&flags.top, "top", 0, - "Return up to N skills (default: all)") - cmd.Flags().StringVar(&flags.orderBy, "orderby", "", - "Sort order forwarded to the service (e.g. 'asc' or 'desc')") + cmd.Flags().IntVar(&flags.top, "top", 0, "Return up to N skills (default: all)") + cmd.Flags().StringVar(&flags.orderBy, "orderby", "", "Sort order forwarded to the service (e.g. 'asc' or 'desc')") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, }) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go index a0d61f89db2..0c90f5c0c60 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go @@ -12,38 +12,29 @@ import ( "github.com/spf13/cobra" ) -// showFlags holds parsed input for the `skill show` command. type showFlags struct { name string output string projectEndpoint string } -// showAction is the show-command implementation. -type showAction struct { - flags *showFlags -} +type showAction struct{ flags *showFlags } -// Run executes the show operation. func (a *showAction) Run(ctx context.Context) error { if err := validateSkillName(a.flags.name); err != nil { return err } - skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) if err != nil { return err } - s, err := skillCtx.client.Get(ctx, a.flags.name) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) } - return printSkillDetail(s, a.flags.output) } -// newShowCommand constructs the `skill show` Cobra command. func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &showFlags{} action := &showAction{flags: flags} @@ -60,9 +51,7 @@ This command returns metadata only. To retrieve the skill body, use flags.name = args[0] flags.output = extCtx.OutputFormat flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") - - ctx := azdext.WithAccessToken(cmd.Context()) - return action.Run(ctx) + return action.Run(azdext.WithAccessToken(cmd.Context())) }, } diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go index 9301782004c..3886c5c5307 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -16,7 +16,6 @@ import ( "github.com/spf13/cobra" ) -// updateFlags holds parsed input for the `skill update` command. type updateFlags struct { name string description string @@ -29,17 +28,12 @@ type updateFlags struct { instructionsSet bool } -// updateAction is the update-command implementation. -type updateAction struct { - flags *updateFlags -} +type updateAction struct{ flags *updateFlags } -// Run executes the update operation. func (a *updateAction) Run(ctx context.Context) error { if err := validateSkillName(a.flags.name); err != nil { return err } - if err := a.validateFlags(); err != nil { return err } @@ -49,8 +43,7 @@ func (a *updateAction) Run(ctx context.Context) error { return err } - // GET-merge-POST so the user can update a single field without losing - // the others. + // GET-merge-POST so a single-field update doesn't drop the others. current, err := skillCtx.client.Get(ctx, a.flags.name) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) @@ -60,8 +53,6 @@ func (a *updateAction) Run(ctx context.Context) error { Description: current.Description, Metadata: current.Metadata, } - - // Apply inline overrides. if a.flags.descriptionSet { req.Description = a.flags.description } @@ -69,7 +60,6 @@ func (a *updateAction) Run(ctx context.Context) error { req.Instructions = a.flags.instructions } - // Apply --file (SKILL.md only) overrides. if a.flags.file != "" { data, readErr := readFileWithLimit(a.flags.file) if readErr != nil { @@ -106,7 +96,6 @@ func (a *updateAction) Run(ctx context.Context) error { return printSkillDetail(updated, outputTable) } -// validateFlags enforces the update-specific flag rules. func (a *updateAction) validateFlags() error { inlineProvided := a.flags.descriptionSet || a.flags.instructionsSet fileProvided := a.flags.file != "" @@ -128,10 +117,10 @@ func (a *updateAction) validateFlags() error { if fileProvided { ext := strings.ToLower(filepath.Ext(a.flags.file)) - switch { - case ext == ".md": + switch ext { + case ".md": return nil - case ext == ".zip": + case ".zip": return exterrors.Validation( exterrors.CodeInvalidSkillFile, "ZIP packages cannot be applied via `skill update`", @@ -148,7 +137,6 @@ func (a *updateAction) validateFlags() error { return nil } -// newUpdateCommand constructs the `skill update` Cobra command. func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &updateFlags{} action := &updateAction{flags: flags} @@ -174,18 +162,13 @@ merged payload to the service.`, flags.descriptionSet = cmd.Flags().Changed("description") flags.instructionsSet = cmd.Flags().Changed("instructions") flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") - - ctx := azdext.WithAccessToken(cmd.Context()) - return action.Run(ctx) + return action.Run(azdext.WithAccessToken(cmd.Context())) }, } - cmd.Flags().StringVar(&flags.description, "description", "", - "New human-readable summary") - cmd.Flags().StringVar(&flags.instructions, "instructions", "", - "New Markdown instructions body") - cmd.Flags().StringVar(&flags.file, "file", "", - "Path to a SKILL.md file whose values override the current skill") + cmd.Flags().StringVar(&flags.description, "description", "", "New human-readable summary") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", "New Markdown instructions body") + cmd.Flags().StringVar(&flags.file, "file", "", "Path to a SKILL.md file whose values override the current skill") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, }) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go index 56305fb006d..6cb1fde67c9 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go @@ -11,19 +11,11 @@ import ( "azureaiskills/internal/exterrors" ) -// skillNamePattern is the fallback skill name regex used when the service -// does not publish a separate constraint. Matches `agent_yaml.ValidateAgentName` -// in `azure.ai.agents` so users see one consistent rule across resource kinds. -// -// - Must start with an alphanumeric character. -// - May contain alphanumerics and hyphens in the middle. -// - Must end with an alphanumeric character (when more than 1 character). -// - Length 1-63 (matches the service's @maxLength on `name`). +// skillNamePattern matches the agent name pattern in azure.ai.agents so users +// see one rule across resource kinds: 1-63 alphanumerics with hyphens only +// in the middle. The service makes the final decision. var skillNamePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) -// validateSkillName returns a structured validation error when name does not -// satisfy [skillNamePattern]. The service has the final say; this function is -// a fast-fail guard to avoid round-tripping obviously invalid names. func validateSkillName(name string) error { trimmed := strings.TrimSpace(name) if trimmed == "" { diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go index fac873158f7..1ea781a8629 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go @@ -11,8 +11,6 @@ import ( "github.com/spf13/cobra" ) -// newVersionCommand returns a `version` subcommand that prints the extension -// version, commit, and build date populated at build time via ldflags. func newVersionCommand() *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go index a80a359054b..0103269b143 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go @@ -3,46 +3,31 @@ package exterrors -// Error codes for skill validation. -// -// These are usually paired with [Validation] when user input, files, -// or option combinations fail validation specific to the skill commands. +// Skill-specific validation error codes. const ( - // CodeInvalidSkillName is used when does not match the - // service-documented (or fallback) skill-name regex. - CodeInvalidSkillName = "invalid_skill_name" - // CodeInvalidSkillFile is used when --file points to a missing, - // unreadable, or unsupported file, or when SKILL.md front matter - // fails to parse. - CodeInvalidSkillFile = "invalid_skill_file" - // CodeSkillArchiveUnsafe is used when a downloaded ZIP archive - // contains an unsafe entry (zip-slip, symlink, oversized, etc.). - CodeSkillArchiveUnsafe = "skill_archive_unsafe" - // CodeSkillOutputCollision is used when `skill download` would - // overwrite an existing file and --force was not supplied. + CodeInvalidSkillName = "invalid_skill_name" + CodeInvalidSkillFile = "invalid_skill_file" + CodeSkillArchiveUnsafe = "skill_archive_unsafe" CodeSkillOutputCollision = "skill_output_collision" + CodeSkillNoPackage = "skill_no_package" ) -// Error codes shared across the extension surface. +// Codes shared across the extension surface. const ( CodeConflictingArguments = "conflicting_arguments" CodeInvalidParameter = "invalid_parameter" CodeMissingProjectEndpoint = "missing_project_endpoint" CodeMissingRequiredField = "missing_required_field" CodeMissingForceFlag = "missing_force_flag" - CodeSkillNotFound = "skill_not_found" - CodeSkillAlreadyExists = "skill_already_exists" - CodeSkillNoPackage = "skill_no_package" ) -// Error codes for auth. const ( //nolint:gosec // error code identifier, not a credential CodeCredentialCreationFailed = "credential_creation_failed" ) -// Operation names for [ServiceFromAzure] errors. -// These are prefixed to the Azure error code (e.g., "create_skill.NotFound"). +// Operation names for ServiceFromAzure errors. Prefixed to the Azure code, +// e.g. "create_skill.NotFound". const ( OpCreateSkill = "create_skill" OpUpdateSkill = "update_skill" diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index 1a379dc3f15..49d611694f7 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -41,7 +41,6 @@ var ( ErrInvalidArchive = errors.New("invalid archive") ) -// ArchiveFormat identifies the wire format of a downloaded skill package. type ArchiveFormat int const ( @@ -61,16 +60,16 @@ func (f ArchiveFormat) String() string { } } -// DetectArchiveFormat sniffs the first bytes of data to identify the archive -// format. The Foundry Skills service is asymmetric: it accepts ZIP on -// POST /skills:import but returns gzip on GET /skills/{name}:download, so we -// detect both rather than trust the Content-Type header. +// DetectArchiveFormat sniffs the first bytes of data. The Foundry Skills +// service is asymmetric: POST /skills:import requires ZIP (gzip yields 415), +// but GET /skills/{name}:download returns gzip — so we sniff rather than +// trust Content-Type. func DetectArchiveFormat(data []byte) ArchiveFormat { switch { case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x03, 0x04}): return ArchiveZip case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x05, 0x06}): - return ArchiveZip // empty zip + return ArchiveZip case len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b: return ArchiveTarGz default: @@ -78,27 +77,17 @@ func DetectArchiveFormat(data []byte) ArchiveFormat { } } -// SafeExtract reads an archive (ZIP or gzip-compressed tar) from data and -// writes its regular-file contents under opts.OutputDir. -// -// The implementation is two-phase: every entry is first written into a -// temporary staging directory under the OS temp dir, validated against the -// safety rules, then copied into OutputDir. A failed extraction leaves -// nothing in OutputDir. -// -// Safety rules (each rejection returns ErrUnsafeEntry): +// SafeExtract extracts a ZIP or gzip-tar archive into opts.OutputDir. // -// - Absolute paths or paths containing `..` components are rejected. -// - Empty names, or names that collapse to "/" or "." after cleaning, are -// rejected. -// - Non-regular files (symlinks, hard links, devices, sockets) are -// rejected. -// - Total entry count is capped at opts.MaxEntries (default 10,000). -// - Total uncompressed byte count is capped at opts.MaxTotalUncompressed -// (default 512 MB). +// Two-phase: entries are first written into a temp staging directory and +// validated against the safety rules below; then symlink-escape checks run +// and files are copied into OutputDir. A failed extraction leaves nothing +// in OutputDir. // -// Executable bits from archive headers are dropped; written files use -// 0600 / 0700 modes against the process umask. +// Rejections (ErrUnsafeEntry): absolute paths, `..` segments, empty names, +// non-regular entries (symlinks, hard links, devices, sockets). +// Caps (ErrLimitExceeded): MaxEntries (default 10,000), +// MaxTotalUncompressed (default 512 MB). func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { if opts.OutputDir == "" { return nil, fmt.Errorf("SafeExtract: OutputDir is required") @@ -146,13 +135,9 @@ func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { } cleanupStaging() - return &ExtractResult{ - Files: files, - TotalBytes: totalBytes, - }, nil + return &ExtractResult{Files: files, TotalBytes: totalBytes}, nil } -// stageFromZip extracts validated entries of a ZIP archive into staging. func stageFromZip(data []byte, staging string, maxEntries int, maxBytes int64) ([]string, int64, error) { zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) if err != nil { @@ -173,8 +158,7 @@ func stageFromZip(data []byte, staging string, maxEntries int, maxBytes int64) ( mode := entry.Mode() switch { case mode.IsDir() || strings.HasSuffix(entry.Name, "/"): - dirPath := filepath.Join(staging, filepath.FromSlash(cleaned)) - if mkErr := os.MkdirAll(dirPath, 0700); mkErr != nil { + if mkErr := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(cleaned)), 0700); mkErr != nil { return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) } continue @@ -200,7 +184,6 @@ func stageFromZip(data []byte, staging string, maxEntries int, maxBytes int64) ( return files, totalBytes, nil } -// stageFromTarGz extracts validated entries of a gzip+tar archive into staging. func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) ([]string, int64, error) { gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { @@ -234,8 +217,7 @@ func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) switch hdr.Typeflag { case tar.TypeReg, tar.TypeRegA: case tar.TypeDir: - dirPath := filepath.Join(staging, filepath.FromSlash(cleaned)) - if mkErr := os.MkdirAll(dirPath, 0700); mkErr != nil { + if mkErr := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(cleaned)), 0700); mkErr != nil { return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) } continue @@ -255,10 +237,9 @@ func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) return files, totalBytes, nil } -// writeStagingEntry creates parent directories and writes a single file -// entry into staging via writeBody. writeBody must copy at most `limit+1` -// bytes from its source; if it writes more than `remaining`, ErrLimitExceeded -// is returned. +// writeStagingEntry creates parent directories and writes one entry into +// staging via writeBody. writeBody must copy at most limit+1 bytes; if it +// writes more than `remaining`, ErrLimitExceeded is returned. func writeStagingEntry( staging, relName string, writeBody func(w io.Writer, limit int64) (int64, error), @@ -272,7 +253,7 @@ func writeStagingEntry( if advertisedSize > 0 && advertisedSize > remaining { return 0, fmt.Errorf("%w: uncompressed size would exceed budget", ErrLimitExceeded) } - f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // stagingPath is inside our trusted staging dir + f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // staging is our trusted temp dir if fErr != nil { return 0, fmt.Errorf("create staging file %q: %w", relName, fErr) } @@ -289,8 +270,6 @@ func writeStagingEntry( return written, nil } -// publishToOutputDir checks for collisions, verifies no destination escapes -// OutputDir via symlinks, then copies every staged file into OutputDir. func publishToOutputDir(staging string, files []string, opts ExtractOptions) error { if mkErr := os.MkdirAll(opts.OutputDir, 0700); mkErr != nil { return fmt.Errorf("create output dir: %w", mkErr) @@ -307,14 +286,17 @@ func publishToOutputDir(staging string, files []string, opts ExtractOptions) err } } + // Resolve OutputDir's real path so we can reject entries that would + // escape through a pre-existing symlink in OutputDir. realOutDir, evalErr := filepath.EvalSymlinks(opts.OutputDir) if evalErr != nil { return fmt.Errorf("resolve output dir path: %w", evalErr) } + // Preflight: create destination directories and verify every resolved + // destination stays inside OutputDir, before any file is copied. for _, rel := range files { - dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) - dstDir := filepath.Dir(dst) + dstDir := filepath.Dir(filepath.Join(opts.OutputDir, filepath.FromSlash(rel))) if mkErr := os.MkdirAll(dstDir, 0700); mkErr != nil { return fmt.Errorf("create output dir for %q: %w", rel, mkErr) } @@ -323,10 +305,7 @@ func publishToOutputDir(staging string, files []string, opts ExtractOptions) err return fmt.Errorf("resolve destination path for %q: %w", rel, evalErr) } if !isUnder(realDstDir, realOutDir) { - return fmt.Errorf( - "%w: %q destination escapes output directory via symlink", - ErrUnsafeEntry, rel, - ) + return fmt.Errorf("%w: %q destination escapes output directory via symlink", ErrUnsafeEntry, rel) } } @@ -340,6 +319,10 @@ func publishToOutputDir(staging string, files []string, opts ExtractOptions) err return nil } +// validateEntryName cleans and validates an archive entry name. Returns the +// cleaned, slash-separated relative path. Rejects `..` even when surrounding +// segments cancel it out (e.g. `a/../b`) — defense against future bugs in +// path.Clean. func validateEntryName(name string) (string, bool) { if name == "" { return "", false @@ -378,12 +361,12 @@ func isUnder(child, parent string) bool { } func copyFile(src, dst string) error { - in, err := os.Open(src) //nolint:gosec // src is inside our trusted staging dir + in, err := os.Open(src) //nolint:gosec // staging is our trusted temp dir if err != nil { return err } defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // dst is inside the user-supplied output dir, written on user behalf + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // user-supplied output dir, written on user behalf if err != nil { return err } @@ -394,13 +377,9 @@ func copyFile(src, dst string) error { return out.Close() } -type bytesReaderAt struct { - data []byte -} +type bytesReaderAt struct{ data []byte } -func newBytesReaderAt(data []byte) *bytesReaderAt { - return &bytesReaderAt{data: data} -} +func newBytesReaderAt(data []byte) *bytesReaderAt { return &bytesReaderAt{data: data} } func (b *bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { if off < 0 || off > int64(len(b.data)) { diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go index 3772a05dc7f..0c1a5f281be 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -11,35 +11,18 @@ import ( "strings" ) -// peekMaxSkillMdBytes caps how much SKILL.md data is read into memory by -// PeekArchiveSkillName. SKILL.md files are expected to be small; a 1 MB cap -// protects against an archive that names a huge file `SKILL.md` and tries to -// exhaust memory during the front-matter peek. -const peekMaxSkillMdBytes = 1 << 20 // 1 MiB - -// peekMaxEntries caps how many zip entries PeekArchiveSkillName scans before -// giving up. SKILL.md is conventionally at the archive root, so scanning -// every entry of a deeply-nested archive is unnecessary and would let a -// malicious archive stall the CLI. -const peekMaxEntries = 1024 +const ( + peekMaxSkillMdBytes = 1 << 20 // 1 MiB cap on the SKILL.md we read into memory. + peekMaxEntries = 1024 // SKILL.md is at the root; cap deep scans. +) -// PeekArchiveSkillName reads the ZIP archive in data and returns the `name` -// field declared in the archive's SKILL.md front matter. -// -// Lookup rules (first match wins): -// -// - A file whose cleaned entry name equals `SKILL.md`. -// - A file whose cleaned entry name is `/SKILL.md`, -// i.e. a SKILL.md exactly one directory below the archive root. -// -// Returns ("", nil) when no SKILL.md is found, when SKILL.md exists but does -// not declare a `name`, or when the front matter cannot be parsed. The caller -// is expected to treat an empty result as "no claim", not as an error: the -// destructive `--force` guard only fires when the archive makes a name claim -// that disagrees with the positional argument. +// PeekArchiveSkillName returns the `name` declared in the archive's SKILL.md +// front matter, or "" when there's no SKILL.md or no `name` claim. Used by +// the destructive `--force` guard: if the archive claims a different name +// than the positional argument, we refuse the delete-then-create. // -// PeekArchiveSkillName accepts only ZIP archives — the upload surface is -// ZIP-only, so this is the only format relevant to the `--force` guard. +// Looks for SKILL.md at the archive root or one directory below. +// ZIP-only — the upload surface is ZIP. func PeekArchiveSkillName(data []byte) (string, error) { zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) if err != nil { @@ -50,10 +33,7 @@ func PeekArchiveSkillName(data []byte) (string, error) { if i >= peekMaxEntries { return "", nil } - if !entry.Mode().IsRegular() { - continue - } - if !isSkillMdEntry(entry.Name) { + if !entry.Mode().IsRegular() || !isSkillMdEntry(entry.Name) { continue } rc, openErr := entry.Open() @@ -67,10 +47,8 @@ func PeekArchiveSkillName(data []byte) (string, error) { } md, parseErr := ParseSkillMd(raw) if parseErr != nil { - // SKILL.md is present but malformed. The upload itself will fail - // validation; for the --force guard we treat this as "no claim" - // so we do not block on a parse error before the service has - // even seen the archive. + // Malformed SKILL.md — let the server reject the upload; the + // --force guard only fires on an unambiguous name mismatch. return "", nil } return md.Name, nil @@ -78,8 +56,6 @@ func PeekArchiveSkillName(data []byte) (string, error) { return "", nil } -// isSkillMdEntry reports whether the given zip entry name refers to a -// SKILL.md file at the archive root or exactly one directory below it. func isSkillMdEntry(name string) bool { cleaned := path.Clean(strings.TrimLeft(name, "/")) if cleaned == "SKILL.md" { @@ -90,8 +66,5 @@ func isSkillMdEntry(name string) bool { return false } dir = strings.TrimSuffix(dir, "/") - if dir == "" || strings.Contains(dir, "/") { - return false - } - return true + return dir != "" && !strings.Contains(dir, "/") } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go index 7bd962d0ba0..6e572282550 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -16,17 +16,12 @@ import ( "github.com/stretchr/testify/require" ) -// zipEntry describes a single file or directory entry to add to a test -// archive. Body is ignored for directories (where Name ends with "/"). type zipEntry struct { Name string Body []byte - // Mode is the file mode; when zero we default to 0644 for files and - // 0755 for directories. - Mode os.FileMode + Mode os.FileMode // 0 defaults to 0644 (files) / 0755 (dirs) } -// makeZip builds an in-memory ZIP archive from entries. func makeZip(t *testing.T, entries []zipEntry) []byte { t.Helper() var buf bytes.Buffer @@ -54,13 +49,11 @@ func makeZip(t *testing.T, entries []zipEntry) []byte { return buf.Bytes() } -// tarEntry describes a tar entry for test archives. type tarEntry struct { Name string Body []byte } -// makeTarGz builds an in-memory gzip-compressed tar archive. func makeTarGz(t *testing.T, entries []tarEntry) []byte { t.Helper() var buf bytes.Buffer diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go index b55d3d08abd..134af3f5abb 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -21,53 +21,36 @@ import ( ) const ( - // DataPlaneAPIVersion is the api-version query parameter applied to every - // request. The Skills surface lives under the `v1` data-plane API version; - // the preview opt-in is communicated separately via the `Foundry-Features` - // header (see SkillsPreviewOptIn below). + // DataPlaneAPIVersion: skills live under v1; preview opt-in is via the + // Foundry-Features header (SkillsPreviewOptIn). DataPlaneAPIVersion = "v1" - // FoundryFeaturesHeader is the HTTP header that carries the preview opt-in. FoundryFeaturesHeader = "Foundry-Features" - // SkillsPreviewOptIn is the required Foundry-Features value for all skill - // operations in this preview. The TypeSpec marks every skill route with - // WithRequiredFoundryPreviewHeader. - SkillsPreviewOptIn = "Skills=V1Preview" + SkillsPreviewOptIn = "Skills=V1Preview" - // ContentTypeJSON is the request/response content type used for the JSON - // surface. ContentTypeJSON = "application/json" // ContentTypeZip is the upload content type for POST /skills:import. The - // TypeSpec declares `application/gzip`, but the live service returns - // 415 Unsupported Media Type on gzip and accepts ZIP per the public docs. - // See https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills. + // TypeSpec declares application/gzip, but the live service returns 415 on + // gzip and accepts ZIP per the public docs: + // https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills. ContentTypeZip = "application/zip" - // ContentTypeGzip is the response content type observed on - // GET /skills/{name}:download. The same TypeSpec / docs mismatch applies - // in reverse: docs say zip, server returns gzip. We accept either. + // ContentTypeGzip is the observed response content type on + // GET /skills/{name}:download. We accept either format on the wire. ContentTypeGzip = "application/gzip" - // BearerScope is the Azure AD scope used for the bearer-token policy. - // Matches the scope used by the rest of the Foundry AI extension surface. //nolint:gosec // OAuth scope identifier, not a credential BearerScope = "https://ai.azure.com/.default" - // userAgentPrefix is the User-Agent value baseline; callers append their - // own version via the userAgent parameter to NewClient. userAgentPrefix = "azd-ext-azure-ai-skills" ) -// Client is the typed REST client for the Foundry Skills data-plane surface. -// All methods include the required preview header and api-version query -// parameter automatically. type Client struct { endpoint string pipeline runtime.Pipeline } -// NewClient returns a Skills client rooted at endpoint (already validated by -// the caller), using cred for bearer-token auth. extensionVersion is appended -// to the User-Agent value emitted by the pipeline. +// NewClient returns a Skills client rooted at endpoint, using cred for +// bearer-token auth. func NewClient(endpoint string, cred azcore.TokenCredential, extensionVersion string) *Client { return newClient(endpoint, cred, extensionVersion, false) } @@ -79,9 +62,8 @@ func newClient(endpoint string, cred azcore.TokenCredential, extensionVersion st } clientOptions := &policy.ClientOptions{ - // IncludeBody is intentionally false: skill create/update bodies carry - // user-authored description and instructions. Body logging will be - // enabled in a follow-up once a sanitizer is in place. + // IncludeBody is intentionally false: skill bodies carry user-authored + // description / instructions and we don't yet have a sanitizer. Logging: policy.LogOptions{IncludeBody: false}, InsecureAllowCredentialWithHTTP: allowHTTP, PerCallPolicies: []policy.Policy{ @@ -131,13 +113,10 @@ func (c *Client) CreateInline(ctx context.Context, req CreateRequest) (*Skill, e if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { return nil, runtime.NewResponseError(resp) } - return decodeSkill(resp.Body) } -// CreatePackage uploads a ZIP archive to POST /skills:import. The CLI does -// not inspect the archive's contents beyond an optional name-claim peek for -// the --force guard; server-side validation owns archive contents otherwise. +// CreatePackage uploads a ZIP archive to POST /skills:import. func (c *Client) CreatePackage(ctx context.Context, archive io.ReadSeeker, archiveSize int64) (*Skill, error) { httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.buildURL("/skills:import", nil)) if err != nil { @@ -159,7 +138,6 @@ func (c *Client) CreatePackage(ctx context.Context, archive io.ReadSeeker, archi if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { return nil, runtime.NewResponseError(resp) } - return decodeSkill(resp.Body) } @@ -180,13 +158,10 @@ func (c *Client) Get(ctx context.Context, name string) (*Skill, error) { if !runtime.HasStatusCode(resp, http.StatusOK) { return nil, runtime.NewResponseError(resp) } - return decodeSkill(resp.Body) } -// Update merges req into the existing skill via POST /skills/{name}. -// The caller is responsible for the GET-merge-POST pattern; this method -// simply sends the supplied body. +// Update sends req as POST /skills/{name}. Caller does GET-merge-POST. func (c *Client) Update(ctx context.Context, name string, req UpdateRequest) (*Skill, error) { body, err := json.Marshal(req) if err != nil { @@ -211,12 +186,10 @@ func (c *Client) Update(ctx context.Context, name string, req UpdateRequest) (*S if !runtime.HasStatusCode(resp, http.StatusOK) { return nil, runtime.NewResponseError(resp) } - return decodeSkill(resp.Body) } -// Delete removes a skill. The service returns a small JSON body describing -// the deletion which the caller may use. +// Delete removes a skill. func (c *Client) Delete(ctx context.Context, name string) (*DeleteResponse, error) { httpReq, err := runtime.NewRequest(ctx, http.MethodDelete, c.skillURL(name, "", nil)) if err != nil { @@ -256,8 +229,7 @@ func (c *Client) Delete(ctx context.Context, name string) (*DeleteResponse, erro return &dr, nil } -// List fetches one page of skills using the supplied options. The returned -// PagedSkills includes pagination cursors the caller can use to fetch more. +// List fetches one page of skills. func (c *Client) List(ctx context.Context, opts ListOptions, afterCursor string) (*PagedSkills, error) { q := url.Values{} if opts.Top > 0 { @@ -294,8 +266,8 @@ func (c *Client) List(ctx context.Context, opts ListOptions, afterCursor string) return &paged, nil } -// ListAll fetches every page transparently and returns the flattened slice. -// If limit is positive, ListAll stops as soon as that many items are collected. +// ListAll fetches every page and returns the flattened slice. If limit is +// positive, ListAll stops once that many items are collected. func (c *Client) ListAll(ctx context.Context, opts ListOptions, limit int) ([]Skill, error) { var all []Skill cursor := "" @@ -315,21 +287,15 @@ func (c *Client) ListAll(ctx context.Context, opts ListOptions, limit int) ([]Sk } } -// Download fetches the skill package and returns the raw bytes. -// -// The Foundry Skills service is asymmetric about archive format: uploads -// (`POST /skills:import`) reject `application/gzip` with 415 and require -// `application/zip`, but downloads return `application/gzip`. Rather than -// pin a single Content-Type, this client accepts either and leaves format -// detection (via magic bytes) to the caller — see DetectArchiveFormat. +// Download fetches the skill package and returns the raw bytes. Accepts +// either ContentTypeZip or ContentTypeGzip (the service is asymmetric); the +// caller uses DetectArchiveFormat to interpret the bytes. func (c *Client) Download(ctx context.Context, name string) ([]byte, error) { httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, ":download", nil)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } addStandardHeaders(httpReq) - // Accept both formats; service ignores the value but the negotiation - // matters for intermediaries that might transform the body. httpReq.Raw().Header.Set("Accept", ContentTypeZip+", "+ContentTypeGzip) resp, err := c.pipeline.Do(httpReq) @@ -356,8 +322,6 @@ func (c *Client) Download(ctx context.Context, name string) ([]byte, error) { return body, nil } -// --- URL and header helpers --- - func (c *Client) buildURL(path string, extraQuery url.Values) string { q := url.Values{} q.Set("api-version", DataPlaneAPIVersion) @@ -369,9 +333,6 @@ func (c *Client) buildURL(path string, extraQuery url.Values) string { return c.endpoint + path + "?" + q.Encode() } -// skillURL builds a per-skill URL with optional action suffix -// (e.g. ":download"). The skill name is URL-path-escaped to handle service- -// accepted characters safely; CLI-side validation rejects most of these. func (c *Client) skillURL(name, suffix string, extraQuery url.Values) string { path := "/skills/" + url.PathEscape(name) + suffix return c.buildURL(path, extraQuery) @@ -401,9 +362,6 @@ func decodeSkill(body io.Reader) (*Skill, error) { return &s, nil } -// streaming wraps an io.ReadSeeker into the io.ReadSeekCloser required by -// runtime.NewRequest's SetBody. We never close the underlying reader; the -// caller owns its lifecycle (the SDK reads then ignores Close on this shim). type readSeekNopCloser struct{ io.ReadSeeker } func (readSeekNopCloser) Close() error { return nil } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go index ce89a1b3fd0..3d8dde3ebe7 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go @@ -18,17 +18,14 @@ import ( "github.com/stretchr/testify/require" ) -// fakeCredential is a no-op token credential for tests. It returns a static -// token without contacting any service. type fakeCredential struct{} func (fakeCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { return azcore.AccessToken{Token: "test-token", ExpiresOn: time.Now().Add(time.Hour)}, nil } -// newTestClient builds a Skills client rooted at the given httptest server URL. -// Uses the unexported insecure-http constructor so the bearer policy doesn't -// reject the plain-HTTP httptest endpoint. +// newTestClient uses the insecure-http constructor so the bearer policy +// doesn't reject the plain-HTTP httptest endpoint. func newTestClient(t *testing.T, srv *httptest.Server) *Client { t.Helper() return newClient(srv.URL, fakeCredential{}, "test", true) diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go index 3f451fa9657..ee2949a152e 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go @@ -3,33 +3,20 @@ // Package skill_api provides a typed REST client for the Foundry Skills // data-plane surface, plus helpers for parsing SKILL.md files and safely -// extracting downloaded ZIP skill packages. +// extracting downloaded skill packages. package skill_api -// Skill is the metadata representation of a Foundry skill returned by -// the Skills data-plane surface. Fields are camelCase in JSON to match the -// published JSON contract for the CLI; the wire format from the service is -// snake_case and is translated by the wire-to-public conversions below. +// Skill is the metadata representation of a Foundry skill. JSON fields are +// camelCase for the published output of the CLI; the wire format is +// snake_case and is translated via skillWire. type Skill struct { - // SkillID is the unique service-assigned identifier. - SkillID string `json:"skillId,omitempty"` - // Name is the unique skill name (validated client-side against the - // alphanumeric-with-hyphens pattern; final decision lives in the service). - Name string `json:"name"` - // HasBlob reports whether the skill was created from a ZIP package. - // Determines whether `download` returns useful content. - HasBlob bool `json:"hasBlob"` - // Description is a human-readable summary; optional. - Description string `json:"description,omitempty"` - // Metadata is the freeform string-to-string map the service stores - // alongside the skill. May be nil. - Metadata map[string]string `json:"metadata,omitempty"` + SkillID string `json:"skillId,omitempty"` + Name string `json:"name"` + HasBlob bool `json:"hasBlob"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } -// skillWire mirrors Skill but uses the snake_case wire field names that the -// Foundry Skills surface returns. Decoding into Skill goes through this struct -// so the public JSON contract for the CLI stays camelCase regardless of how -// the service evolves. type skillWire struct { SkillID string `json:"skill_id,omitempty"` Name string `json:"name"` @@ -48,10 +35,8 @@ func (w skillWire) toSkill() Skill { } } -// CreateRequest is the inline JSON body for `POST /skills`. Either both of -// Description and Instructions are set (inline mode) or they come from a -// parsed SKILL.md file. The CLI populates Name from the positional argument -// and never trusts the front-matter value. +// CreateRequest is the JSON body for POST /skills. The CLI populates Name +// from the positional argument and never trusts a value from front matter. type CreateRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` @@ -59,21 +44,20 @@ type CreateRequest struct { Metadata map[string]string `json:"metadata,omitempty"` } -// UpdateRequest is the merged JSON body for `POST /skills/{name}`. Only -// non-empty fields are sent; the action layer performs the GET-merge-POST. +// UpdateRequest is the merged JSON body for POST /skills/{name}. type UpdateRequest struct { Description string `json:"description,omitempty"` Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } -// DeleteResponse is the JSON body returned by `DELETE /skills/{name}`. +// DeleteResponse is the JSON body returned by DELETE /skills/{name}. type DeleteResponse struct { Name string `json:"name"` Deleted bool `json:"deleted"` } -// PagedSkills is one page of the `GET /skills` response. +// PagedSkills is one page of the GET /skills response. type PagedSkills struct { Data []Skill `json:"data"` FirstID string `json:"firstId,omitempty"` @@ -81,7 +65,6 @@ type PagedSkills struct { HasMore bool `json:"hasMore"` } -// pagedSkillsWire is the snake_case wire form of PagedSkills. type pagedSkillsWire struct { Data []skillWire `json:"data"` FirstID string `json:"first_id,omitempty"` @@ -104,12 +87,8 @@ func (w pagedSkillsWire) toPagedSkills() PagedSkills { return out } -// ListOptions configures a `GET /skills` request. Zero values mean "let the -// service apply its defaults". +// ListOptions configures a GET /skills request. Zero values use service defaults. type ListOptions struct { - // Top is the per-page item limit. The service caps this at 100. - Top int - // OrderBy is forwarded to the `order` query parameter - // (typically `asc` or `desc`). + Top int OrderBy string } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go index 7be31f2a3ff..3a291e21d60 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go @@ -12,38 +12,20 @@ import ( "go.yaml.in/yaml/v3" ) -// SkillMd represents the parsed contents of a SKILL.md file. -// -// SKILL.md files are Markdown files with a YAML front matter block delimited -// by three-dash lines. The CLI extracts the structured fields it knows about -// (Name, Description, Metadata) and uses the remaining Markdown body as the -// skill's `instructions`. Any unrecognized front matter keys are preserved -// verbatim in RawFrontMatter so callers can forward them to the service. +// SkillMd is the parsed form of a SKILL.md document: a YAML front matter block +// delimited by `---` lines, followed by a Markdown body that becomes the +// skill's `instructions`. type SkillMd struct { - // Name is the optional `name` value from the YAML front matter. If the - // positional command argument differs, the positional value wins and - // the caller prints a one-line warning to stderr. - Name string - // Description is the optional human-readable summary. - Description string - // Metadata is the optional string-to-string map from front matter. - Metadata map[string]string - // Instructions is the Markdown body that follows the closing `---`. Leading - // whitespace and a single trailing newline are preserved verbatim. - Instructions string - // RawFrontMatter contains every parsed front-matter key (including those - // already exposed as named fields). Useful for forwarding unknown - // service-recognized fields without losing information. + Name string + Description string + Metadata map[string]string + Instructions string RawFrontMatter map[string]any } -// ParseSkillMd reads a SKILL.md document from data and returns its components. -// -// The document must start with a YAML front matter block delimited by lines -// containing only `---`. Both the opening and closing delimiters are -// required. Empty input, missing/unparsable front matter, and YAML errors -// all return a non-nil error that callers should wrap in a structured -// validation error. +// ParseSkillMd parses a SKILL.md document. Both `---` delimiters are required. +// Missing or unparsable front matter returns an error suitable for wrapping +// in a structured validation error. func ParseSkillMd(data []byte) (*SkillMd, error) { if len(bytes.TrimSpace(data)) == 0 { return nil, fmt.Errorf("SKILL.md is empty") @@ -56,8 +38,8 @@ func ParseSkillMd(data []byte) (*SkillMd, error) { fmBytes := data[openIdx:closeIdx] bodyStart := closeIdx + len(frontMatterDelimiter) - // Skip a single newline after the closing delimiter so the body does not - // start with a leading blank line that the user did not write. + // Strip a single newline after the closing delimiter so the body doesn't + // start with a blank line the user didn't write. if bodyStart < len(data) { if data[bodyStart] == '\r' && bodyStart+1 < len(data) && data[bodyStart+1] == '\n' { bodyStart += 2 @@ -71,8 +53,6 @@ func ParseSkillMd(data []byte) (*SkillMd, error) { return nil, fmt.Errorf("parse SKILL.md front matter: %w", err) } if raw == nil { - // `---\n---` with no body is technically valid YAML (null document) - // but useless for skill creation. Treat as missing. return nil, fmt.Errorf("SKILL.md front matter is empty") } @@ -82,51 +62,40 @@ func ParseSkillMd(data []byte) (*SkillMd, error) { } if v, ok := raw["name"]; ok { - s, sErr := frontMatterString("name", v) - if sErr != nil { - return nil, sErr + s, err := frontMatterString("name", v) + if err != nil { + return nil, err } out.Name = s } if v, ok := raw["description"]; ok { - s, sErr := frontMatterString("description", v) - if sErr != nil { - return nil, sErr + s, err := frontMatterString("description", v) + if err != nil { + return nil, err } out.Description = s } if v, ok := raw["metadata"]; ok { - m, mErr := frontMatterStringMap("metadata", v) - if mErr != nil { - return nil, mErr + m, err := frontMatterStringMap("metadata", v) + if err != nil { + return nil, err } out.Metadata = m } - return out, nil } const frontMatterDelimiter = "---" -// findFrontMatterBounds locates the opening `---` and closing `---` markers -// for the YAML front matter block. The returned indices bracket the YAML body -// (exclusive of the delimiter lines themselves). -// -// The opening delimiter must be the first non-empty line of the file. Any -// leading whitespace lines are skipped to be tolerant of UTF-8 BOMs and -// editor-introduced blank lines. +// findFrontMatterBounds returns the byte offsets that bracket the YAML body +// (exclusive of the delimiter lines themselves). Leading blank lines are +// allowed. func findFrontMatterBounds(data []byte) (open, close int, err error) { scanner := bufio.NewScanner(bytes.NewReader(data)) - // Allow up to 1 MiB per line so giant front matter blocks do not hit the - // default 64 KiB cap. Skills bodies are bounded by the server; front - // matter is small, but we set a roomy limit anyway. scanner.Buffer(make([]byte, 0, 4096), 1<<20) sawOpen := false lineOffset := 0 - // We need the byte offset of each line's start, including its newline - // terminator. bufio.Scanner doesn't expose offsets directly, so we - // recompute them by tracking how many bytes we've advanced. cur := data lineNum := 0 for { @@ -174,7 +143,6 @@ func findFrontMatterBounds(data []byte) (open, close int, err error) { break } } - return 0, 0, fmt.Errorf("SKILL.md front matter is missing its closing '---' delimiter") } diff --git a/cli/azd/extensions/azure.ai.skills/internal/version/version.go b/cli/azd/extensions/azure.ai.skills/internal/version/version.go index 52188c90118..dcd79ce6238 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/version/version.go +++ b/cli/azd/extensions/azure.ai.skills/internal/version/version.go @@ -3,9 +3,9 @@ package version +// Populated at build time via ldflags. var ( - // Populated at build time - Version = "dev" // Default value for development builds + Version = "dev" Commit = "none" BuildDate = "unknown" ) From 6f9e6ea196032f619d739e998adee393e0bc2cb1 Mon Sep 17 00:00:00 2001 From: huimiao Date: Mon, 18 May 2026 19:52:50 +0800 Subject: [PATCH 08/13] fix(azure.ai.skills): drop unused scanner, stream archive peek, reject symlinked copy destinations, use errors.AsType --- .../internal/cmd/skill_create.go | 20 ++++++++++++------ .../internal/pkg/skill_api/archive.go | 21 ++++++++++++++++++- .../internal/pkg/skill_api/archive_peek.go | 9 +++++--- .../pkg/skill_api/archive_peek_test.go | 15 ++++++------- .../internal/pkg/skill_api/skill_md.go | 4 ---- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go index ec7d941419e..5430ead395a 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -201,15 +201,24 @@ func printCreateResult(s *skill_api.Skill, format string) error { // wiping an unrelated skill on a typo. Returns nil when the archive omits // `name` (no claim) or when the names agree. func verifyPackageNameMatches(archivePath, positionalName string) error { - data, readErr := os.ReadFile(archivePath) //nolint:gosec // user-supplied path read on user's behalf - if readErr != nil { + f, openErr := os.Open(archivePath) //nolint:gosec // user-supplied path opened on user's behalf + if openErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot open %s: %s", archivePath, openErr), + "verify the file is readable", + ) + } + defer f.Close() + info, statErr := f.Stat() + if statErr != nil { return exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf("cannot read %s: %s", archivePath, readErr), + fmt.Sprintf("cannot stat %s: %s", archivePath, statErr), "verify the file is readable", ) } - archiveName, peekErr := skill_api.PeekArchiveSkillName(data) + archiveName, peekErr := skill_api.PeekArchiveSkillName(f, info.Size()) if peekErr != nil { return exterrors.Validation( exterrors.CodeInvalidSkillFile, @@ -393,8 +402,7 @@ func shouldSuppressWarning(noPrompt bool, format string) bool { } func isNotFound(err error) bool { - var respErr *azcore.ResponseError - if errors.As(err, &respErr) { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { return respErr.StatusCode == 404 } return false diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index 49d611694f7..c3d25cdefbf 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -366,7 +366,26 @@ func copyFile(src, dst string) error { return err } defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // user-supplied output dir, written on user behalf + + // Even with --force, never follow a symlink at the destination: O_TRUNC + // would otherwise overwrite whatever the link points at, potentially + // outside OutputDir. Lstat first, reject non-regular entries, and remove + // any pre-existing regular file so the O_EXCL open below owns the path. + if info, lstatErr := os.Lstat(dst); lstatErr == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf( + "%w: destination %q already exists and is not a regular file", + ErrUnsafeEntry, dst, + ) + } + if rmErr := os.Remove(dst); rmErr != nil { + return fmt.Errorf("remove existing destination %q: %w", dst, rmErr) + } + } else if !errors.Is(lstatErr, os.ErrNotExist) { + return fmt.Errorf("stat destination %q: %w", dst, lstatErr) + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) //nolint:gosec // user-supplied output dir, written on user behalf if err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go index 0c1a5f281be..932a703f8af 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -22,9 +22,12 @@ const ( // than the positional argument, we refuse the delete-then-create. // // Looks for SKILL.md at the archive root or one directory below. -// ZIP-only — the upload surface is ZIP. -func PeekArchiveSkillName(data []byte) (string, error) { - zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) +// ZIP-only — the upload surface is ZIP. The caller passes an io.ReaderAt +// + size (typically an *os.File and its stat size) so the archive is not +// slurped into memory: zip.NewReader streams central-directory and entry +// payloads via ReadAt as needed. +func PeekArchiveSkillName(r io.ReaderAt, size int64) (string, error) { + zr, err := zip.NewReader(r, size) if err != nil { return "", fmt.Errorf("%w: %w", ErrInvalidArchive, err) } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go index 3cfc1c9303b..d86617228cd 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go @@ -4,6 +4,7 @@ package skill_api import ( + "bytes" "testing" "github.com/stretchr/testify/require" @@ -14,7 +15,7 @@ func TestPeekArchiveSkillName_RootLevel(t *testing.T) { {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, {Name: "other.txt", Body: []byte("ignored")}, }) - got, err := PeekArchiveSkillName(archive) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) require.NoError(t, err) require.Equal(t, "foo", got) } @@ -24,7 +25,7 @@ func TestPeekArchiveSkillName_OneDirDeep(t *testing.T) { {Name: "greeting/"}, {Name: "greeting/SKILL.md", Body: []byte("---\nname: greeting\n---\nbody\n")}, }) - got, err := PeekArchiveSkillName(archive) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) require.NoError(t, err) require.Equal(t, "greeting", got) } @@ -33,14 +34,14 @@ func TestPeekArchiveSkillName_TooDeepIgnored(t *testing.T) { archive := makeZip(t, []zipEntry{ {Name: "a/b/SKILL.md", Body: []byte("---\nname: deep\n---\nbody\n")}, }) - got, err := PeekArchiveSkillName(archive) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) require.NoError(t, err) require.Equal(t, "", got) } func TestPeekArchiveSkillName_NoSkillMd(t *testing.T) { archive := makeZip(t, []zipEntry{{Name: "README.md", Body: []byte("hi")}}) - got, err := PeekArchiveSkillName(archive) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) require.NoError(t, err) require.Equal(t, "", got) } @@ -49,7 +50,7 @@ func TestPeekArchiveSkillName_MissingNameField(t *testing.T) { archive := makeZip(t, []zipEntry{ {Name: "SKILL.md", Body: []byte("---\ndescription: hi\n---\nbody\n")}, }) - got, err := PeekArchiveSkillName(archive) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) require.NoError(t, err) require.Equal(t, "", got) } @@ -58,12 +59,12 @@ func TestPeekArchiveSkillName_MalformedYAMLReturnsEmpty(t *testing.T) { archive := makeZip(t, []zipEntry{ {Name: "SKILL.md", Body: []byte("not valid front matter")}, }) - got, err := PeekArchiveSkillName(archive) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) require.NoError(t, err) require.Equal(t, "", got) } func TestPeekArchiveSkillName_InvalidZip(t *testing.T) { - _, err := PeekArchiveSkillName([]byte("this is not zip")) + _, err := PeekArchiveSkillName(bytes.NewReader([]byte("this is not zip")), int64(len("this is not zip"))) require.Error(t, err) } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go index 3a291e21d60..09b5683b9dc 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go @@ -4,7 +4,6 @@ package skill_api import ( - "bufio" "bytes" "fmt" "strings" @@ -91,9 +90,6 @@ const frontMatterDelimiter = "---" // (exclusive of the delimiter lines themselves). Leading blank lines are // allowed. func findFrontMatterBounds(data []byte) (open, close int, err error) { - scanner := bufio.NewScanner(bytes.NewReader(data)) - scanner.Buffer(make([]byte, 0, 4096), 1<<20) - sawOpen := false lineOffset := 0 cur := data From 3d7bb9a4fa439f7b86d88dc14572337d2bd6174c Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 14:06:52 +0800 Subject: [PATCH 09/13] fix(skills): address PR feedback on download/update help text - skill_download: drop 're-create with create --force' hint from the no-package error; users downloading the skill don't have it locally to re-create. - skill_update: in 'update' long help, route ZIP package updates to 'create --force' and call out that skills are not versioned so it's a destructive (delete-then-recreate) path. --- .../azure.ai.skills/internal/cmd/skill_download.go | 4 +--- .../azure.ai.skills/internal/cmd/skill_update.go | 9 +++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go index c9fa4f13816..014d7ba7f9b 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -73,9 +73,7 @@ func (a *downloadAction) Run(ctx context.Context) error { exterrors.CodeSkillNoPackage, fmt.Sprintf("skill %q has no downloadable package", a.flags.name), "only skills created from a `.zip` archive have a downloadable "+ - "package. Use `azd ai skill show ` to inspect metadata; "+ - "re-create with `azd ai skill create --file .zip --force` "+ - "if you want a downloadable copy.", + "package. Use `azd ai skill show ` to inspect this skill's metadata.", ) } diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go index 3886c5c5307..90d7a65e117 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -149,10 +149,15 @@ func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Pass any subset of: --description "..." --instructions "..." or: - --file ./SKILL.md (parsed locally; .zip is not accepted here) + --file ./SKILL.md (parsed locally) The CLI fetches the current skill, merges your changes locally, then POSTs the -merged payload to the service.`, +merged payload to the service. + +ZIP packages are not accepted here. To replace a skill's package, use +` + "`azd ai skill create --file .zip --force`" + `. Skills are not +versioned, so that path is destructive: it deletes the existing skill before +re-creating it from the archive.`, Example: ` azd ai skill update my-skill --description "Updated summary" azd ai skill update my-skill --file ./SKILL.md`, Args: cobra.ExactArgs(1), From 344f564929798aa03c8013dc5a021eb1b2dd0840 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 15:17:27 +0800 Subject: [PATCH 10/13] fix(skills): restore context.go and metadata.go; rename skill_context.go Restore the original context.go and metadata.go files that were deleted, to respect the existing template structure. Rename skill_context.go to skill_client.go to avoid naming overlap with the restored context command. --- .../azure.ai.skills/internal/cmd/context.go | 165 ++++++++++++++++++ .../azure.ai.skills/internal/cmd/metadata.go | 15 ++ .../azure.ai.skills/internal/cmd/root.go | 5 +- .../cmd/{skill_context.go => skill_client.go} | 0 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/context.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/metadata.go rename cli/azd/extensions/azure.ai.skills/internal/cmd/{skill_context.go => skill_client.go} (100%) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/context.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/context.go new file mode 100644 index 00000000000..088d21b22e6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/context.go @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func newContextCommand() *cobra.Command { + return &cobra.Command{ + Use: "context", + Short: "Get the context of the azd project & environment.", + RunE: func(cmd *cobra.Command, args []string) error { + // Create a new context that includes the azd access token + ctx := azdext.WithAccessToken(cmd.Context()) + + // Create a new azd client + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + + defer azdClient.Close() + + // Wait for debugger if AZD_EXT_DEBUG is set + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + hasEnv := false + + getConfigResponse, err := azdClient.UserConfig().Get(ctx, &azdext.GetUserConfigRequest{ + Path: "", + }) + if err == nil { + if getConfigResponse.Found { + color.HiWhite("User Config") + var userConfig map[string]string + err := json.Unmarshal(getConfigResponse.Value, &userConfig) + if err == nil { + jsonBytes, err := json.MarshalIndent(userConfig, "", " ") + if err == nil { + fmt.Println(string(jsonBytes)) + } + } + } + } + + getProjectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err == nil { + color.Cyan("Project:") + + projectValues := map[string]string{ + "Name": getProjectResponse.Project.Name, + "Path": getProjectResponse.Project.Path, + } + + for key, value := range projectValues { + fmt.Printf("%s: %s\n", color.HiWhiteString(key), value) + } + fmt.Println() + } else { + color.Yellow("WARNING: No azd project found in current working directory") + fmt.Printf("Run %s to create a new project.\n", color.CyanString("azd init")) + return nil + } + + var currentEnvName string + + getEnvResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err == nil { + currentEnvName = getEnvResponse.Environment.Name + hasEnv = true + } else { + color.Yellow("WARNING: No azd environment(s) found.") + fmt.Printf("Run %s to create a new environment.\n", color.CyanString("azd env new")) + return nil + } + + environments := []string{} + envListResponse, err := azdClient.Environment().List(ctx, &azdext.EmptyRequest{}) + if err == nil { + for _, env := range envListResponse.Environments { + environments = append(environments, env.Name) + } + } + + if len(environments) == 0 { + fmt.Println("No environments found") + } + + if hasEnv { + color.Cyan("Environments:") + for _, env := range environments { + envLine := env + if env == currentEnvName { + envLine += color.HiWhiteString(" (selected)") + } + + fmt.Printf("- %s\n", envLine) + } + + fmt.Println() + + getValuesResponse, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: currentEnvName, + }) + if err == nil { + color.Cyan("Environment values:") + for _, pair := range getValuesResponse.KeyValues { + fmt.Printf("%s: %s\n", color.HiWhiteString(pair.Key), color.HiBlackString(pair.Value)) + } + fmt.Println() + } + + deploymentContextResponse, err := azdClient.Deployment().GetDeploymentContext(ctx, &azdext.EmptyRequest{}) + if err == nil { + scopeMap := map[string]string{ + "Tenant ID": deploymentContextResponse.AzureContext.Scope.TenantId, + "Subscription ID": deploymentContextResponse.AzureContext.Scope.SubscriptionId, + "Location": deploymentContextResponse.AzureContext.Scope.Location, + "Resource Group": deploymentContextResponse.AzureContext.Scope.ResourceGroup, + } + + color.Cyan("Deployment Context:") + for key, value := range scopeMap { + if value == "" { + value = "N/A" + } + + fmt.Printf("%s: %s\n", color.HiWhiteString(key), value) + } + fmt.Println() + + color.Cyan("Provisioned Azure Resources:") + for _, resourceId := range deploymentContextResponse.AzureContext.Resources { + resource, err := arm.ParseResourceID(resourceId) + if err == nil { + fmt.Printf( + "- %s (%s)\n", + resource.Name, + color.HiBlackString(resource.ResourceType.String()), + ) + } + } + fmt.Println() + } + } + + return nil + }, + } +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/metadata.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/metadata.go new file mode 100644 index 00000000000..583922f5aeb --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/metadata.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newMetadataCommand(rootCmd *cobra.Command) *cobra.Command { + return azdext.NewMetadataCommand("1.0", "azure.ai.skills", func() *cobra.Command { + return rootCmd + }) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go index 6678f0e3497..fdd93cf3b74 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go @@ -46,9 +46,8 @@ a Foundry project.`, rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) rootCmd.AddCommand(newVersionCommand()) - rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.skills", func() *cobra.Command { - return rootCmd - })) + rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newCreateCommand(extCtx)) rootCmd.AddCommand(newUpdateCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_client.go similarity index 100% rename from cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go rename to cli/azd/extensions/azure.ai.skills/internal/cmd/skill_client.go From c9801d7a9d85da700e07c54bc1b8de713f199d4c Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 16:11:12 +0800 Subject: [PATCH 11/13] test(skills): cover archive ext, extract error mapping, delete preflight, exterrors Add unit tests for previously uncovered pure-logic helpers: - cmd/skill_download_test.go: archiveExtension format mapping, classifyExtractError sentinel-to-LocalError translation (unsafe/limit/collision/invalid + pass-through), and downloadAction.Run name validation short-circuit. - cmd/skill_delete_test.go: deleteAction.Run rejects invalid names early and surfaces CodeMissingForceFlag when --no-prompt is set without --force. - cmd/skill_validate_test.go: extend TestIsNotFound to cover real *azcore.ResponseError (404 vs 500, wrapped). - exterrors/errors_test.go: validation/dependency/auth factories and ServiceFromAzure (wraps *azcore.ResponseError, pass-through, nil); package now at 100% coverage. --- .../internal/cmd/skill_delete_test.go | 33 ++++++++ .../internal/cmd/skill_download_test.go | 83 +++++++++++++++++++ .../internal/cmd/skill_validate_test.go | 6 ++ .../internal/exterrors/errors_test.go | 74 +++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go new file mode 100644 index 00000000000..ab9bed1a01c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestDeleteAction_RejectsInvalidName(t *testing.T) { + a := &deleteAction{flags: &deleteFlags{name: "_bad"}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestDeleteAction_NoPromptRequiresForce(t *testing.T) { + a := &deleteAction{flags: &deleteFlags{name: "my-skill", noPrompt: true, force: false}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingForceFlag, le.Code) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go new file mode 100644 index 00000000000..d01765317c8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "testing" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestArchiveExtension(t *testing.T) { + cases := []struct { + format skill_api.ArchiveFormat + want string + }{ + {skill_api.ArchiveZip, ".zip"}, + {skill_api.ArchiveTarGz, ".tar.gz"}, + {skill_api.ArchiveUnknown, ".bin"}, + } + for _, c := range cases { + require.Equal(t, c.want, archiveExtension(c.format), "format=%v", c.format) + } +} + +func TestClassifyExtractError_UnsafeEntry(t *testing.T) { + wrapped := fmt.Errorf("entry escapes: %w", skill_api.ErrUnsafeEntry) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillArchiveUnsafe, le.Code) +} + +func TestClassifyExtractError_LimitExceeded(t *testing.T) { + wrapped := fmt.Errorf("too big: %w", skill_api.ErrLimitExceeded) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillArchiveUnsafe, le.Code) +} + +func TestClassifyExtractError_Collision(t *testing.T) { + wrapped := fmt.Errorf("collision: %w", skill_api.ErrCollision) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillOutputCollision, le.Code) + require.Contains(t, le.Suggestion, "/tmp/out", "suggestion should mention output dir") +} + +func TestClassifyExtractError_InvalidArchive(t *testing.T) { + wrapped := fmt.Errorf("bad header: %w", skill_api.ErrInvalidArchive) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidParameter, le.Code) +} + +func TestClassifyExtractError_UnknownPassthrough(t *testing.T) { + original := errors.New("io: short read") + err := classifyExtractError(original, "/tmp/out") + require.Same(t, original, err, "unknown errors must propagate unwrapped") +} + +func TestDownloadAction_RejectsInvalidName(t *testing.T) { + a := &downloadAction{flags: &downloadFlags{name: "bad name"}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go index e495d861388..d9c52a92600 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go @@ -5,10 +5,12 @@ package cmd import ( "errors" + "fmt" "testing" "azureaiskills/internal/exterrors" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" ) @@ -157,4 +159,8 @@ func TestTruncate(t *testing.T) { func TestIsNotFound(t *testing.T) { require.False(t, isNotFound(nil)) require.False(t, isNotFound(errors.New("oops"))) + require.True(t, isNotFound(&azcore.ResponseError{StatusCode: 404})) + require.False(t, isNotFound(&azcore.ResponseError{StatusCode: 500})) + wrapped := fmt.Errorf("get skill: %w", &azcore.ResponseError{StatusCode: 404}) + require.True(t, isNotFound(wrapped), "wrapped 404 ResponseError must still match") } diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go new file mode 100644 index 00000000000..ec3ea956dad --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestValidation_BuildsLocalError(t *testing.T) { + err := Validation("code-x", "boom", "do y") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, "code-x", le.Code) + require.Equal(t, "boom", le.Message) + require.Equal(t, "do y", le.Suggestion) + require.Equal(t, azdext.LocalErrorCategoryValidation, le.Category) +} + +func TestDependency_BuildsLocalError(t *testing.T) { + err := Dependency("code-y", "missing dep", "install z") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, "code-y", le.Code) + require.Equal(t, azdext.LocalErrorCategoryDependency, le.Category) +} + +func TestAuth_BuildsLocalError(t *testing.T) { + err := Auth("code-z", "unauthorized", "run azd auth login") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, "code-z", le.Code) + require.Equal(t, azdext.LocalErrorCategoryAuth, le.Category) +} + +func TestServiceFromAzure_WrapsResponseError(t *testing.T) { + reqURL, err := url.Parse("https://example.com/skills/my-skill") + require.NoError(t, err) + respErr := &azcore.ResponseError{ + ErrorCode: "NotFound", + StatusCode: http.StatusNotFound, + RawResponse: &http.Response{ + StatusCode: http.StatusNotFound, + Status: "404 Not Found", + Body: io.NopCloser(strings.NewReader("")), + Request: &http.Request{Method: http.MethodGet, URL: reqURL}, + }, + } + wrapped := ServiceFromAzure(respErr, "OpGetSkill") + var se *azdext.ServiceError + require.True(t, errors.As(wrapped, &se)) + require.Equal(t, "NotFound", se.ErrorCode) + require.Equal(t, http.StatusNotFound, se.StatusCode) + require.Equal(t, "OpGetSkill", se.ServiceName) +} + +func TestServiceFromAzure_PassThroughForNonAzcoreError(t *testing.T) { + original := errors.New("network down") + err := ServiceFromAzure(original, "OpAny") + require.Same(t, original, err, "non-azcore errors must propagate unchanged") +} + +func TestServiceFromAzure_NilError(t *testing.T) { + require.NoError(t, ServiceFromAzure(nil, "OpAny")) +} From fec617312bc0cfdb4840db9382e4ae46295aaca1 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 16:37:01 +0800 Subject: [PATCH 12/13] fix(skills): use slices.Contains for traversal segment check go fix -diff was reporting two conflicting suggestions on the same loop (slicescontains and stringsseq), causing the analyzer to exit non-zero. With bash -e in the lint-go workflow that non-zero exit short-circuited the script, breaking CI even though there was no real diff to print. Replacing the manual loop with slices.Contains resolves the conflict so go fix -diff exits 0. --- .../azure.ai.skills/internal/pkg/skill_api/archive.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index c3d25cdefbf..30e4868c728 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -14,6 +14,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" ) @@ -338,10 +339,8 @@ func validateEntryName(name string) (string, bool) { if strings.HasPrefix(withoutTrailing, "/") { return "", false } - for _, part := range strings.Split(withoutTrailing, "/") { - if part == ".." { - return "", false - } + if slices.Contains(strings.Split(withoutTrailing, "/"), "..") { + return "", false } cleaned := path.Clean(withoutTrailing) if cleaned == "" || cleaned == "." || cleaned == "/" { From ad851b7dd8026c81e6e42f3fc1e45d0d0d6e8898 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 17:20:56 +0000 Subject: [PATCH 13/13] fix(skills): address PR feedback - bugs, security hardening, and tests Co-authored-by: wbreza <6540159+wbreza@users.noreply.github.com> --- .../internal/cmd/skill_create_test.go | 125 ++++++++++++++++++ .../internal/cmd/skill_list.go | 17 +-- .../internal/cmd/skill_update.go | 8 +- .../internal/cmd/skill_update_test.go | 60 +++++++++ .../internal/pkg/skill_api/archive.go | 11 +- .../internal/pkg/skill_api/archive_peek.go | 10 +- .../pkg/skill_api/archive_peek_test.go | 7 +- .../internal/pkg/skill_api/archive_test.go | 26 ++++ .../internal/pkg/skill_api/models.go | 33 ++--- 9 files changed, 253 insertions(+), 44 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go new file mode 100644 index 00000000000..c7f71636738 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// --- createAction.Run early-exit paths (no network) --- + +func TestCreateAction_RejectsInvalidName(t *testing.T) { + a := &createAction{flags: &createFlags{name: "_bad"}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestCreateAction_NoPromptWithNoInput(t *testing.T) { + a := &createAction{flags: &createFlags{name: "my-skill", noPrompt: true}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingRequiredField, le.Code) +} + +// TestCreateAction_ConflictingFlagsViRun exercises the full Run path (not just +// selectCreateMode) so that the validation is also reached via the command +// entry-point. +func TestCreateAction_ConflictingFlagsViaRun(t *testing.T) { + a := &createAction{flags: &createFlags{ + name: "my-skill", + descriptionSet: true, + description: "desc", + file: "SKILL.md", + noPrompt: true, + }} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeConflictingArguments, le.Code) +} + +// --- verifyPackageNameMatches --- + +func TestVerifyPackageNameMatches_NameMatches(t *testing.T) { + path := writeZipWithSkillMd(t, "my-skill") + require.NoError(t, verifyPackageNameMatches(path, "my-skill")) +} + +func TestVerifyPackageNameMatches_NameMismatch(t *testing.T) { + path := writeZipWithSkillMd(t, "other-skill") + err := verifyPackageNameMatches(path, "my-skill") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestVerifyPackageNameMatches_NoSkillMd(t *testing.T) { + // Archive without SKILL.md: no name claim → --force allowed. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("README.md") + require.NoError(t, err) + _, _ = w.Write([]byte("hi")) + require.NoError(t, zw.Close()) + p := writeTempFile(t, buf.Bytes(), "*.zip") + require.NoError(t, verifyPackageNameMatches(p, "my-skill")) +} + +func TestVerifyPackageNameMatches_MalformedSkillMd(t *testing.T) { + // Malformed SKILL.md: PeekArchiveSkillName now propagates the error, + // so --force must be blocked to prevent accidental skill deletion. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("SKILL.md") + require.NoError(t, err) + _, _ = w.Write([]byte("not valid front matter")) + require.NoError(t, zw.Close()) + p := writeTempFile(t, buf.Bytes(), "*.zip") + err = verifyPackageNameMatches(p, "my-skill") + require.Error(t, err, "malformed SKILL.md must block --force to prevent accidental deletion") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +// helpers + +func writeZipWithSkillMd(t *testing.T, skillName string) string { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("SKILL.md") + require.NoError(t, err) + _, _ = w.Write([]byte("---\nname: " + skillName + "\n---\nbody\n")) + require.NoError(t, zw.Close()) + return writeTempFile(t, buf.Bytes(), "*.zip") +} + +func writeTempFile(t *testing.T, data []byte, pattern string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), pattern) + require.NoError(t, err) + _, err = f.Write(data) + require.NoError(t, err) + require.NoError(t, f.Close()) + return filepath.Clean(f.Name()) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go index 06f1badf9be..810d6271d3f 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go @@ -14,8 +14,6 @@ import ( ) type listFlags struct { - top int - orderBy string output string projectEndpoint string } @@ -27,11 +25,7 @@ func (a *listAction) Run(ctx context.Context) error { if err != nil { return err } - items, err := skillCtx.client.ListAll( - ctx, - skill_api.ListOptions{Top: a.flags.top, OrderBy: a.flags.orderBy}, - a.flags.top, - ) + items, err := skillCtx.client.ListAll(ctx, skill_api.ListOptions{}, 0) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpListSkills) } @@ -45,11 +39,8 @@ func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List Foundry skills in the project.", - Long: `List skills in the resolved Foundry project. - -Without --top, the CLI iterates all pages transparently into one flat list. -With --top, the CLI stops once that many items have been collected.`, - Args: cobra.NoArgs, + Long: `List all skills in the resolved Foundry project.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { flags.output = extCtx.OutputFormat flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") @@ -57,8 +48,6 @@ With --top, the CLI stops once that many items have been collected.`, }, } - cmd.Flags().IntVar(&flags.top, "top", 0, "Return up to N skills (default: all)") - cmd.Flags().StringVar(&flags.orderBy, "orderby", "", "Sort order forwarded to the service (e.g. 'asc' or 'desc')") azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, }) diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go index 90d7a65e117..c8a329a791c 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -50,8 +50,9 @@ func (a *updateAction) Run(ctx context.Context) error { } req := skill_api.UpdateRequest{ - Description: current.Description, - Metadata: current.Metadata, + Description: current.Description, + Instructions: current.Instructions, + Metadata: current.Metadata, } if a.flags.descriptionSet { req.Description = a.flags.description @@ -124,7 +125,8 @@ func (a *updateAction) validateFlags() error { return exterrors.Validation( exterrors.CodeInvalidSkillFile, "ZIP packages cannot be applied via `skill update`", - "use `azd ai skill create --file .zip --force` to replace the skill", + "use `azd ai skill create --file .zip --force` to replace the skill "+ + "(this deletes the existing skill first; skills are not versioned)", ) default: return exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go new file mode 100644 index 00000000000..94069257b0d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// --- updateAction.Run early-exit paths (no network) --- + +func TestUpdateAction_RejectsInvalidName(t *testing.T) { + a := &updateAction{flags: &updateFlags{ + name: "_bad", + descriptionSet: true, + description: "new desc", + }} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestUpdateAction_ValidInputFailsAtEndpoint(t *testing.T) { + // A fully valid inline update with no endpoint configured must fail at + // endpoint resolution (not at flag validation or name validation). + a := &updateAction{flags: &updateFlags{ + name: "my-skill", + descriptionSet: true, + description: "new desc", + }} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingProjectEndpoint, le.Code, + "should fail at endpoint resolution with no project configured") +} + +// TestUpdateAction_ZipSuggestionMentionsDestructive verifies that the error +// message for ZIP files on `update` tells the user the operation is +// destructive (skills are not versioned). +func TestUpdateAction_ZipSuggestionMentionsDestructive(t *testing.T) { + a := &updateAction{flags: &updateFlags{file: "skill.zip"}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) + require.Contains(t, le.Suggestion, "deletes", + "suggestion must warn that the operation is destructive") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go index 30e4868c728..0f9c6c6a707 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -205,10 +205,10 @@ func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) if hdrErr != nil { return nil, 0, fmt.Errorf("read tar entry: %w", hdrErr) } - entryCount++ - if entryCount > maxEntries { + if entryCount >= maxEntries { return nil, 0, fmt.Errorf("%w: entry count exceeds %d", ErrLimitExceeded, maxEntries) } + entryCount++ cleaned, ok := validateEntryName(hdr.Name) if !ok { @@ -217,6 +217,9 @@ func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) switch hdr.Typeflag { case tar.TypeReg, tar.TypeRegA: + if hdr.Linkname != "" { + return nil, 0, fmt.Errorf("%w: %q regular-file entry has unexpected Linkname", ErrUnsafeEntry, hdr.Name) + } case tar.TypeDir: if mkErr := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(cleaned)), 0700); mkErr != nil { return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) @@ -336,6 +339,10 @@ func validateEntryName(name string) (string, bool) { if len(withoutTrailing) >= 2 && withoutTrailing[1] == ':' { return "", false } + // Reject UNC paths (\\server\share normalizes to //server/share). + if strings.HasPrefix(withoutTrailing, "//") { + return "", false + } if strings.HasPrefix(withoutTrailing, "/") { return "", false } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go index 932a703f8af..198633a4fc1 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -41,18 +41,16 @@ func PeekArchiveSkillName(r io.ReaderAt, size int64) (string, error) { } rc, openErr := entry.Open() if openErr != nil { - return "", nil + return "", fmt.Errorf("open SKILL.md entry: %w", openErr) } + defer rc.Close() //nolint:gocritic // one entry opened per iteration; loop exits after first SKILL.md raw, readErr := io.ReadAll(io.LimitReader(rc, peekMaxSkillMdBytes)) - _ = rc.Close() if readErr != nil { - return "", nil + return "", fmt.Errorf("read SKILL.md entry: %w", readErr) } md, parseErr := ParseSkillMd(raw) if parseErr != nil { - // Malformed SKILL.md — let the server reject the upload; the - // --force guard only fires on an unambiguous name mismatch. - return "", nil + return "", fmt.Errorf("parse SKILL.md: %w", parseErr) } return md.Name, nil } diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go index d86617228cd..a4d0da8dd8d 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go @@ -55,13 +55,12 @@ func TestPeekArchiveSkillName_MissingNameField(t *testing.T) { require.Equal(t, "", got) } -func TestPeekArchiveSkillName_MalformedYAMLReturnsEmpty(t *testing.T) { +func TestPeekArchiveSkillName_MalformedYAMLReturnsError(t *testing.T) { archive := makeZip(t, []zipEntry{ {Name: "SKILL.md", Body: []byte("not valid front matter")}, }) - got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) - require.NoError(t, err) - require.Equal(t, "", got) + _, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.Error(t, err, "malformed SKILL.md must propagate a parse error") } func TestPeekArchiveSkillName_InvalidZip(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go index 6e572282550..d788822be7b 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -223,6 +223,9 @@ func TestValidateEntryName(t *testing.T) { {"../evil", "", false}, {"a/../b", "", false}, {`C:\Windows\Temp`, "", false}, + // UNC paths + {"//server/share", "", false}, + {`\\server\share`, "", false}, } for _, c := range cases { got, ok := validateEntryName(c.in) @@ -230,3 +233,26 @@ func TestValidateEntryName(t *testing.T) { require.Equal(t, c.want, got, "input=%q", c.in) } } + +func TestSafeExtract_TarGzRejectsEntryWithLinkname(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + // Regular-file entry with a non-empty Linkname field (should be rejected). + hdr := &tar.Header{ + Name: "SKILL.md", + Mode: 0644, + Typeflag: tar.TypeReg, + Size: 5, + Linkname: "/etc/passwd", + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + _, extractErr := SafeExtract(buf.Bytes(), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, extractErr) + require.True(t, errors.Is(extractErr, ErrUnsafeEntry)) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go index ee2949a152e..886f80ed689 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go @@ -10,28 +10,31 @@ package skill_api // camelCase for the published output of the CLI; the wire format is // snake_case and is translated via skillWire. type Skill struct { - SkillID string `json:"skillId,omitempty"` - Name string `json:"name"` - HasBlob bool `json:"hasBlob"` - Description string `json:"description,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + SkillID string `json:"skillId,omitempty"` + Name string `json:"name"` + HasBlob bool `json:"hasBlob"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } type skillWire struct { - SkillID string `json:"skill_id,omitempty"` - Name string `json:"name"` - HasBlob bool `json:"has_blob,omitempty"` - Description string `json:"description,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + SkillID string `json:"skill_id,omitempty"` + Name string `json:"name"` + HasBlob bool `json:"has_blob,omitempty"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } func (w skillWire) toSkill() Skill { return Skill{ - SkillID: w.SkillID, - Name: w.Name, - HasBlob: w.HasBlob, - Description: w.Description, - Metadata: w.Metadata, + SkillID: w.SkillID, + Name: w.Name, + HasBlob: w.HasBlob, + Description: w.Description, + Instructions: w.Instructions, + Metadata: w.Metadata, } }