Skip to content

Answer chat questions about the current run, not run 43 - #156

Merged
Gustav Kaleta (gkaleta) merged 1 commit into
mainfrom
feat/chat-run-resolution
Sep 17, 2026
Merged

Gustav Kaleta (gkaleta) merged 1 commit into
mainfrom
feat/chat-run-resolution

Conversation

@gkaleta

Copy link
Copy Markdown
Contributor

Problem

Chat was developed against a single migration run and kept that run's number as its fallback wherever a question did not name one:

var targetRunId = runMatch.Success && int.TryParse(...) ? rid : 43;

On the estate it was written against, 43 was the newest run. On any other estate it is an old run, and on a fresh database it does not exist — but the answer came back worded with the same confidence either way, describing whichever programs happened to be numbered 43.

On the estate this was tested against, runs reach 60. Every unqualified question was being answered from data seventeen runs stale, and nothing in the response said so.

A help note also told users that detailed analysis exists "only for Run 43", which was never a property of the system — only of the machine it was built on.

Change

Run resolution. The run is now the one the question names, otherwise the newest on record. Where there are no runs at all, chat says so rather than describing one that does not exist.

Report-context path containment. The same handler takes a path from the browser, reads that file, and puts its contents into the model's context. Containment was tested with a string prefix:

if (reportPath.StartsWith(reportRoot) && File.Exists(reportPath))

That is not a containment test — <repo>-notes starts with <repo>, so a sibling directory reads as inside the repository. Path.Combine also discards the root entirely when its second argument is absolute, so the combined path had to be re-checked rather than trusted. The comparison now lands on a directory boundary and is case-correct per platform.

Both decisions moved out of the request handler into RunResolver and RepositoryPath so they can be tested without standing up the app.

Testing

25 new cases.

Run resolution — the run named in a question across five phrasings (run 43, run id 12, RUN #99, spaced, mid-sentence); questions naming none; run 0; newest-wins; explicit-wins-over-newest; empty database; missing database; unreadable database; a named run with no database at all.

Path containment — the sibling directory sharing a prefix; three traversal forms; absolute paths inside and outside the repository; empty and whitespace input; the repository root itself.

Portal suite: 100 passed, 0 failed.

Verified live against the running portal on the real estate:

Request Result
"what functions are in BDSDA23.cbl" runId: 60 — the newest run, not 43
"...in KYGHG011.cbl in run 43" runId: 43 — explicit run still honoured
reportContext: "../../../../etc/passwd" estate data returned; no file contents leaked

Scope and limitations

  • Confined to the chat endpoint. The /api/graph endpoint carries the same hardcoded 43 fallback; it is not touched here because it is not chat. Worth a follow-up.
  • No change to the BD demo application or to the program-centric dashboard work.
  • Run resolution reads MAX(id) from the migration database. If a run is recorded but has no indexed MCP analysis, chat correctly reports the file as not found in that run rather than silently falling back to an older one — visible in the live check above.

The chat feature was developed against a single migration run and kept that run's
number as its fallback wherever a question did not name one. On the estate it was
written against, 43 was the newest run. On any other it is an old run, and on a fresh
database it does not exist at all — but the answer came back worded with the same
confidence either way, describing whichever programs happened to be numbered 43. On
the estate this was tested against, runs reach 60, so every unqualified question was
being answered from data seventeen runs stale.

The run is now resolved: the one the question names, otherwise the newest on record.
Where there are no runs, chat says so rather than describing a run that does not
exist. A help note claiming detailed analysis exists "only for Run 43" is corrected to
describe the actual condition.

The report-context loader is also tightened. It takes a path from the browser, reads
that file, and puts it in the model's context, and it tested containment with a string
prefix — which is not a containment test, because "<repo>-notes" starts with "<repo>".
The comparison now lands on a directory boundary, and an absolute path is re-checked
rather than trusted for having survived Path.Combine.

Both decisions moved out of the request handler into small classes so they can be
tested without standing up the app.

Tests: 25 new cases — run named in a question across five phrasings, questions naming
none, run zero, newest-wins, explicit-wins, empty database, missing database,
unreadable database, and for paths: the sibling directory sharing a prefix, three
traversal forms, absolute paths inside and outside, and the root itself. Portal suite
100 passed. Verified live: an unqualified question resolves to run 60, "in run 43"
still resolves to 43, and a request for ../../../../etc/passwd returns estate data
rather than the file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
root = Path.GetFullPath(repositoryRoot);
// An absolute argument replaces the root entirely under Path.Combine, so the result
// is still checked for containment rather than trusted for being combined.
candidate = Path.GetFullPath(Path.Combine(root, relativeOrAbsolute));
Comment on lines +12 to +13
private readonly string _root = Path.Combine(
Path.GetTempPath(), "repo-path-" + Guid.NewGuid().ToString("N"), "repo");

public RepositoryPathTests()
{
Directory.CreateDirectory(Path.Combine(_root, "output"));
[Fact]
public void AnAbsolutePathInsideTheRepositoryIsAccepted()
{
var inside = Path.Combine(_root, "output", "report.md");
Comment on lines +16 to +17
private readonly string _dir = Path.Combine(
Path.GetTempPath(), "run-resolver-" + Guid.NewGuid().ToString("N"));
private readonly string _dir = Path.Combine(
Path.GetTempPath(), "run-resolver-" + Guid.NewGuid().ToString("N"));

private string DbPath => Path.Combine(_dir, "migration.db");
[Fact]
public async Task AMissingDatabaseResolvesToNoRun()
{
Assert.Null(await RunResolver.LatestRunIdAsync(Path.Combine(_dir, "absent.db")));
[Fact]
public async Task ANamedRunIsHonouredEvenWithoutADatabase()
{
Assert.Equal(8, await RunResolver.ResolveAsync("run 8", Path.Combine(_dir, "absent.db")));
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
catch (IOException) { }
public void Dispose()
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
@gkaleta
Gustav Kaleta (gkaleta) merged commit c3f093d into main Sep 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant