Skip to content

Commit 0d4d905

Browse files
Work item tools, modes and documentation
- ado_work_item, ado_query, ado_create, ado_update (with preview and confirm for many items), ado_comment (@[name] mentions), ado_link; same CLI commands - checks before writing: fields by name, allowed values, required fields, people by name, state transitions; markdown to HTML and back - modes po / dev / full register only their tool sets - the PAT is only sent to Azure DevOps hosts; rule errors in plain words - README in both languages, SECURITY.md, issue templates
1 parent 3837754 commit 0d4d905

24 files changed

Lines changed: 2446 additions & 110 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Bug report
2+
description: Something does not work as described.
3+
labels: [bug]
4+
body:
5+
- type: markdown
6+
attributes:
7+
value: |
8+
Thanks for reporting!
9+
10+
**Please do not paste work items, wiki pages, code, names or any other data from your own or your customers'
11+
Azure DevOps organizations.** Reproduce the problem with neutral names (e.g. organization `contoso`,
12+
project `Demo`) or describe it in general terms.
13+
Security problems: use **Security → Report a vulnerability** instead of an issue.
14+
- type: input
15+
id: version
16+
attributes:
17+
label: Version
18+
description: Output of `adolens version`.
19+
placeholder: ado-lens 0.1.0
20+
validations:
21+
required: true
22+
- type: textarea
23+
id: what
24+
attributes:
25+
label: What happened
26+
description: The tool or command you used, with its parameters, and what you got.
27+
placeholder: |
28+
ado_fields type=Bug
29+
-> "no work item type 'Bug'", although ...
30+
validations:
31+
required: true
32+
- type: textarea
33+
id: expected
34+
attributes:
35+
label: What you expected
36+
validations:
37+
required: true
38+
- type: textarea
39+
id: status
40+
attributes:
41+
label: Status (optional)
42+
description: Output of `adolens status`. Replace organization, project and user names that belong to a customer.
43+
render: text
44+
- type: input
45+
id: environment
46+
attributes:
47+
label: Environment
48+
description: Windows version, process (Agile / Scrum / CMMI / inherited), Git or TFVC, Claude Desktop or Claude Code.
49+
- type: checkboxes
50+
id: privacy
51+
attributes:
52+
label: Confidential data
53+
options:
54+
- label: This report contains no work items, code, names or data from customer organizations.
55+
required: true

‎.github/ISSUE_TEMPLATE/config.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
blank_issues_enabled: false
2+
contact_links:
3+
- name: Report a security vulnerability
4+
url: https://github.com/willowdev-code/ado-lens/security/advisories/new
5+
about: Please report security problems privately, not as a public issue.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Feature request
2+
description: An idea for a new tool or a better answer from an existing one.
3+
labels: [enhancement]
4+
body:
5+
- type: markdown
6+
attributes:
7+
value: |
8+
Please describe the use case with neutral names — no work items, code or data from customer organizations.
9+
- type: textarea
10+
id: problem
11+
attributes:
12+
label: What you are trying to do
13+
description: The question you want Claude to answer, or the task you still do by hand in Azure DevOps.
14+
validations:
15+
required: true
16+
- type: textarea
17+
id: proposal
18+
attributes:
19+
label: What would help
20+
description: A new tool, a parameter, a different answer — whatever you have in mind.
21+
- type: checkboxes
22+
id: privacy
23+
attributes:
24+
label: Confidential data
25+
options:
26+
- label: This request contains no work items, code or data from customer organizations.
27+
required: true

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,12 @@ First skeleton.
1212
- Local metadata (SQLite) per connection: work item types by backlog level, states, transitions, fields, required flags,
1313
defaults and allowed values; history of structure changes between refreshes.
1414
- Tools: `ado_status`, `ado_fields`.
15+
- Work items (tool set `work`): `ado_work_item` (compact item with markdown text, links, code links; comments and
16+
history on request), `ado_query` (WIQL or saved query), `ado_create` and `ado_update` (fields by name, allowed values,
17+
people by name, state only along the transitions, markdown → HTML, soft length notes; updates of more than
18+
`confirmBulkAbove` items are previewed and written with the preview's token), `ado_comment` (`@[name]` mentions),
19+
`ado_link`. Same commands in the CLI (`item`, `query`, `create`, `update`, `comment`, `link`).
20+
- Modes `po` / `dev` / `full` choose the tool sets Claude sees; `config --add-toolset`, `--confirm-above`.
21+
- Inherited process names, bugs shown on the requirement backlog, disabled types listed by name.
22+
- The PAT is only ever sent to Azure DevOps hosts. `adolens api <path>` for development.
1523
- Usage log and `adolens stats` (answer sizes in tokens, times, empty answers).

‎README.md‎

Lines changed: 185 additions & 39 deletions
Large diffs are not rendered by default.

‎README.pl.md‎

Lines changed: 184 additions & 37 deletions
Large diffs are not rendered by default.

‎SECURITY.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Security policy
2+
3+
## Supported versions
4+
5+
Only the latest release receives fixes. Please check the
6+
[Releases](https://github.com/willowdev-code/ado-lens/releases) page and upgrade before reporting.
7+
8+
## Reporting a vulnerability
9+
10+
Please **do not open a public issue** for security problems.
11+
12+
Report them privately through GitHub: open the **Security** tab of this repository and choose
13+
**Report a vulnerability** (or use [this link](https://github.com/willowdev-code/ado-lens/security/advisories/new)).
14+
You will get an answer within a few days; once a fix is released the report can be published with credit to you,
15+
if you wish.
16+
17+
When describing the problem, **do not include work items, wiki pages, code, names or any other data from your own or
18+
your customers' Azure DevOps organizations**. A minimal reproduction with neutral names (for example a `Contoso`
19+
organization and a `Demo` project) is enough.
20+
21+
## How the personal access token is handled
22+
23+
- **What it can do:** ADO Lens does exactly what the PAT and the account allow — it adds no restrictions and no
24+
extra rights. Create the PAT for the one organization you need, with the scopes you want Claude to use and a short
25+
expiry. A read-only PAT (Work Items / Code / Wiki / Build: Read) makes the whole server read-only.
26+
- **Storage:** `adolens login` stores the PAT in **Windows Credential Manager** as a generic credential of the
27+
current Windows user (`ado-lens:<connection>`), kept on this machine only (not roamed) and encrypted by Windows with
28+
the user's logon secret. It can be viewed or removed in *Control Panel → Credential Manager → Windows Credentials*,
29+
or with `adolens logout --connection <name>`.
30+
- **Order:** Credential Manager is read first. Only when it holds no PAT for the connection are the environment
31+
variables `ADOLENS_PAT_<CONNECTION>` and then `AZURE_DEVOPS_PAT` used (CI, testing). `adolens status` shows where the
32+
PAT came from and warns when a variable holds a different one.
33+
- **Never in files or command lines:** the PAT is typed with hidden input (or piped through standard input with
34+
`--pat-stdin`). It is not accepted as a command-line argument, so it does not end up in shell history or process
35+
listings. It is not written to `adolens.json`, to the Claude configuration, to the metadata or usage files, or to logs.
36+
- **Other servers:** `adolens status` reports other MCP entries in the Claude configuration that keep a PAT in plain
37+
text (for example an older Azure DevOps server). It never changes them; remove the PAT there yourself.
38+
- **Network:** the server talks only to `https://dev.azure.com/<organization>` (and the `vsrm` / `vssps` hosts of the
39+
same organization) over HTTPS, with the PAT in the `Authorization` header. It opens no network ports.
40+
41+
## Scope
42+
43+
ADO Lens runs locally and talks to Claude over stdin/stdout (MCP). It keeps settings, the copy of the work item
44+
structure and the usage log in `%LOCALAPPDATA%\ado-lens`; nothing is sent anywhere except the Azure DevOps requests
45+
that tools make. Text read from work items, wiki pages and code is returned to Claude as data, and the server
46+
instructions tell Claude to treat it as data, not as instructions. Relevant reports are, for example, ways to make it
47+
leak the PAT, to write outside its own folders, to call hosts other than Azure DevOps, or to make a read tool change data.

‎install.ps1‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ else {
9595
}
9696
Copy-Item $f.FullName $dst -Force
9797
}
98-
foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'LICENSE') {
98+
foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'SECURITY.md', 'LICENSE') {
9999
if (Test-Path (Join-Path $src $f)) { Copy-Item (Join-Path $src $f) $InstallDir -Force }
100100
}
101101
}

‎pack.ps1‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Write-Host 'publishing (self-contained)...' -ForegroundColor Cyan
3737
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed' }
3838

3939
Remove-Item (Join-Path $stage 'bin\*.pdb') -Force -ErrorAction SilentlyContinue
40-
foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'LICENSE') {
40+
foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'SECURITY.md', 'LICENSE') {
4141
Copy-Item (Join-Path $PSScriptRoot $f) $stage -Force
4242
}
4343

‎src/AdoLens/AdoClient.cs‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,26 +64,61 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
6464
static string WithApiVersion(string url) =>
6565
url.Contains("api-version=", StringComparison.Ordinal) ? url : url + (url.Contains('?') ? "&" : "?") + "api-version=" + ApiVersion;
6666

67-
public async Task<JsonElement> GetAsync(string url, CancellationToken ct = default)
67+
public Task<JsonElement> GetAsync(string url, CancellationToken ct = default) => SendAsync(HttpMethod.Get, url, null, null, ct);
68+
69+
public Task<JsonElement> PostAsync(string url, object body, CancellationToken ct = default) =>
70+
SendAsync(HttpMethod.Post, url, body, "application/json", ct);
71+
72+
/// <summary>JSON Patch (work item create and update).</summary>
73+
public Task<JsonElement> PatchAsync(string url, object operations, CancellationToken ct = default) =>
74+
SendAsync(HttpMethod.Patch, url, operations, "application/json-patch+json", ct);
75+
76+
/// <summary>
77+
/// Sends one request: relative URLs go to the organization, absolute ones only to Azure DevOps hosts (vssps, vsrm),
78+
/// so a URL taken from data can never carry the PAT anywhere else.
79+
/// </summary>
80+
public async Task<JsonElement> SendAsync(HttpMethod method, string url, object? body, string? contentType, CancellationToken ct = default)
6881
{
6982
url = WithApiVersion(url);
83+
if (Uri.TryCreate(url, UriKind.Absolute, out var abs) && abs.Scheme is "https" or "http" && !IsAzureDevOpsHost(abs))
84+
throw new AdoApiException($"refusing to send the PAT to {abs.Host}: only Azure DevOps hosts are allowed");
85+
var payload = body is null ? null : JsonSerializer.SerializeToUtf8Bytes(body, BodyJson);
7086
for (var attempt = 1; ; attempt++)
7187
{
72-
using var req = new HttpRequestMessage(HttpMethod.Get, url);
88+
using var req = new HttpRequestMessage(method, url);
7389
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
90+
if (payload is not null)
91+
{
92+
req.Content = new ByteArrayContent(payload);
93+
req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/json");
94+
}
7495
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
7596
if (attempt < MaxAttempts && RetryDelay(res) is { } delay)
7697
{
7798
await Delay(delay, ct);
7899
continue;
79100
}
80101
await EnsureSuccessAsync(res, ct);
81-
await using var body = await res.Content.ReadAsStreamAsync(ct);
82-
using var doc = await JsonDocument.ParseAsync(body, cancellationToken: ct);
102+
await using var stream = await res.Content.ReadAsStreamAsync(ct);
103+
if (res.Content.Headers.ContentLength == 0) return default;
104+
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
83105
return doc.RootElement.Clone();
84106
}
85107
}
86108

109+
static readonly JsonSerializerOptions BodyJson = new(JsonSerializerDefaults.Web)
110+
{
111+
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
112+
};
113+
114+
static bool IsAzureDevOpsHost(Uri uri) =>
115+
uri.Scheme == "https" && (uri.Host.Equals("dev.azure.com", StringComparison.OrdinalIgnoreCase)
116+
|| uri.Host.EndsWith(".dev.azure.com", StringComparison.OrdinalIgnoreCase)
117+
|| uri.Host.EndsWith(".visualstudio.com", StringComparison.OrdinalIgnoreCase));
118+
119+
/// <summary>Organization-level host of another Azure DevOps service, e.g. "vssps" or "vsrm".</summary>
120+
public string ServiceUrl(string service) => $"https://{service}.dev.azure.com/{Uri.EscapeDataString(organization)}/";
121+
87122
/// <summary>The "value" array of a list response.</summary>
88123
public async Task<List<JsonElement>> GetListAsync(string url, CancellationToken ct = default)
89124
{
@@ -132,6 +167,7 @@ static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken
132167
}
133168
catch (JsonException) { }
134169
message ??= text.Length > 300 ? text[..300] + "…" : text;
170+
message = Explain(message);
135171

136172
var hint = res.StatusCode == HttpStatusCode.Forbidden ? " (the PAT or the account lacks the permission or scope for this)" : "";
137173
throw new AdoApiException(
@@ -140,6 +176,24 @@ static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken
140176
}
141177

142178
static string Describe(Uri? uri) => uri is null ? "request" : uri.GetLeftPart(UriPartial.Path);
179+
180+
static readonly System.Text.RegularExpressions.Regex RuleError =
181+
new(@"Rule Error for field (?<field>.+?)\. Error code: (?<code>\w+)", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
182+
183+
/// <summary>Work item rule errors (TF401320) in plain words; Claude can fix the request from them.</summary>
184+
internal static string Explain(string message)
185+
{
186+
var parts = RuleError.Matches(message).Select(m => m.Groups["code"].Value switch
187+
{
188+
"Required" => $"field '{m.Groups["field"].Value}' is required for this change",
189+
"InvalidListValue" or "InvalidNotEmpty" => $"field '{m.Groups["field"].Value}' has a value that is not allowed (ado_fields field=… lists the values)",
190+
"InvalidEmpty" => $"field '{m.Groups["field"].Value}' must be empty",
191+
"InvalidNotOldValue" => $"field '{m.Groups["field"].Value}' must change",
192+
"InvalidChangedValue" or "ReadOnly" => $"field '{m.Groups["field"].Value}' cannot be changed",
193+
var code => $"field '{m.Groups["field"].Value}': {code}",
194+
}).Distinct().ToList();
195+
return parts.Count == 0 ? message : string.Join("; ", parts) + $" ({message.Split('\n')[0].Trim()})";
196+
}
143197
}
144198

145199
/// <summary>Small helpers for reading JsonElement without exceptions.</summary>

0 commit comments

Comments
 (0)