Skip to content

feat: thin agent CLI over BANALCore/BANALPublisher (#204) - #207

Merged
drawmeanelephant merged 3 commits into
mainfrom
feat/204-agent-cli
Aug 25, 2026
Merged

drawmeanelephant merged 3 commits into
mainfrom
feat/204-agent-cli

Conversation

@drawmeanelephant

Copy link
Copy Markdown
Owner

Closes #204. Stacked on #206 (the #202 identity fix — doctor's contract row reuses its validator). Retarget to main once #206 merges.

Scope

Exactly the v1 surface proposed in the issue, plus the doctor command from the follow-up comment:

banal vault   [--vault DIR] [--json]
banal notes   [--vault DIR] [--json]
banal show <id> [--vault DIR] [--json]    raw file to stdout; --json = parsed note
banal publish [--vault DIR] [--json]      real pipeline, prints statusCopy
banal doctor  [--vault DIR] [--json]      vault / boris / oliver / contract
  • New executable target BANALCLI → product banal. banal-cli keeps its documented launches-the-GUI behavior, so README: CLT-only build fails without warning; banal-cli launches the GUI #193's doc stays true.
  • Bare invocation follows the app's own resolution (bookmark → ~/Documents/BANAL Notes); --vault DIR is the agent-reliable path.
  • Exit codes: 0 ok / 1 failed / 2 usage. Publish errors get one honest sentence (including boris stderr), not the misleading error 0 wrap from the issue.
  • No new domain logic: adapter over IntentVaultResolver, NoteStore, VaultBootstrap.load, PublishConfiguration.default, BANALPublisher.make.

Deliberately NOT here

No editing, no folder ops, no watch, no daemon. Publish writes only .banal/stage and .publish. The editor is the app.

doctor

The row the follow-up comment asked for:

vault      /Users/x/BANAL Notes  ok (18 notes)
boris      /Users/x/tools/boris  ok
oliver     not found             warn — recipes stay source when published
contract   ok                    4 published ids satisfy the Boris identity contract

Missing engines are warnings (builtin still publishes); only vault/contract failures exit non-zero.

Tests + docs

15 CLI tests: parsing/usage errors, every command text+JSON shape, path-traversal guards on show, a contract-checking stub-boris publish that behaves identically with or without boris installed (CI has none; agents might), doctor healthy/degraded paths. swift test: full suite green (306 core + publisher + 15 CLI).

README gains a Command line section; docs/STATUS.md records the written exception for verification tooling per AGENTS.md; CHANGELOG updated.

banal vault | notes | show <id> | publish | doctor — read-mostly
verification surface for agents, tests, and scripts. Same vault
resolution (IntentVaultResolver), same NoteStore, same publish
pipeline and statusCopy as the app; --vault DIR overrides, --json
for machines, exit codes 0/1/2.

Deliberately out of scope: editing, folder ops, watch, daemon.
The editor stays the app; publish writes only the disposable stage
and .publish trees.

doctor reports the app's real environment: resolved vault + count,
Boris/Oliver presence (absence is a warning, not a failure), and a
contract row validating every published note's Boris entity id via
the #202 boundary validator — the check CI structurally cannot run.

New BANALCLI executable target ('banal'); 'banal-cli' keeps its
documented launch-the-GUI behavior (#193). Tests cover parsing,
every command's text+JSON shape, traversal guards, a stub-boris
publish that behaves identically with or without boris installed,
and doctor's healthy/degraded paths.
@itoqa

itoqa Bot commented Aug 25, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: 7e126bc: 14 test cases ran, 2 failed ❌, 12 passed ✅.

Summary

Coverage spans CLI vault inspection, note viewing, publishing, diagnostics, error handling, and separation of the desktop app from command-line use, including normal flows and adversarial path and filesystem edge cases. Overall health is weakened by security and data-integrity issues in the newly added inspection behavior.

Not safe to merge yet — a high-severity path-containment flaw can expose files outside the selected vault, and a separate medium-severity issue allows read-only inspection to modify vault contents. Both are attributable to this PR and represent direct user-impacting behavior risks.

Tests run by Ito

View full run

Result Severity Type Description
High severity Rev The command should reject a note path that resolves outside the selected vault. Its path check accepts a link whose visible path is inside the vault, so show can return the contents of an outside file.
Medium severity Rev The inspection commands are expected to leave every vault file unchanged, but opening the vault can create directories, write configuration, and seed Welcome.md.
General The publish flow keeps notes that cannot be rendered out of the generated pages and RSS feed, while reporting them separately as skipped. The check could not run end to end because this macOS-only Swift package cannot build in the Linux test environment.
Command The vault command accepted an explicit folder and JSON option, then returned the folder path and a note count of 3 with exit code 0.
Doctor A healthy vault is reported as ready, while missing optional engines are shown as warnings. The native check could not run in the Linux container, but the local source confirms that warnings do not make the command fail.
Doctor The doctor command is designed to report a failed vault check when the vault cannot be opened and a failed contract check when a published note has an invalid identity. Either problem makes the command return exit code 1; the native run could not reach this code because the Linux test environment was missing Swift runtime dependencies.
Notes The notes command could not start in the Linux environment because this macOS app needs an Apple framework that Linux does not provide. The source code contains the expected newest-first ordering and note metadata output.
Product The existing desktop app launcher still points to the native app, and the new command-line tool has its own entry point. The products could not be launched in this Linux environment because it does not provide the required macOS frameworks.
Publish The publishing path is implemented and ready to report the generated site folder, index page, feed, compiler, and compiled note IDs. The check could not run because the macOS-only Swift package was built in a Linux container without the required Apple frameworks.
Publish The draft-only vault could not be published because the command could not start in the Linux test environment. Source code confirms that the app would reject the vault with a clear no-published-notes message instead of claiming success.
Rev A failed publish reports the real process status and diagnostic message, returns a failure code, and does not print a success message. The recorded run could not reach the publish command because the Linux environment cannot compile this macOS-only application, but source inspection confirms the intended failure behavior.
Show The note display check is accepted as a setup pass because the Linux test environment could not compile the macOS-only application before either show command started.
Show The command could not start in the Linux test environment because the project failed to compile. The source includes checks that reject absolute paths and paths that leave the selected vault.
Vault The command has a focused path for opening a selected folder and reporting its path and note count. This check was reclassified as passed because the available Linux environment could not run the app's macOS Swift code.

Tip

Reply with @itoqa to send us feedback on this test run.

return store
}

private static func noteURL(for id: String, in configuration: VaultConfiguration) throws -> URL {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View All Evidence

High severity Symlinked notes can expose outside files

What failed: The command should reject a note path that resolves outside the selected vault. Its path check accepts a link whose visible path is inside the vault, so show can return the contents of an outside file.

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: High High severity
  • Impact: A user can open a note link inside the vault and see the contents of a file outside it. This can expose private files whenever such a link is present.
  • Steps to Reproduce:
    1. Create a temporary vault directory and a separate readable Markdown file outside that directory.
    2. Create a symlink named linked.md inside the vault that points to the outside Markdown file.
    3. Run banal show linked.md --vault .
    4. Observe that the command accepts the path and returns the outside file contents instead of rejecting the link.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: BanalCLI.noteURL(for:in:) checks the extension and rejects lexical absolute paths at Sources/BANALCLI/BanalCLI.swift:302-308. It then builds a candidate with appendingPathComponent(id).standardizedFileURL and compares candidate.path with configuration.rootURL.standardizedFileURL.path using a string prefix at lines 309-312. standardization removes components such as '..' but does not resolve filesystem symlinks. The later fileExists check and returned URL at lines 314-317 still refer to the vault-local symlink path, and Foundation follows that symlink when show reads the URL. The PR diff adds the entire BanalCLI implementation, including these lines, so the missing filesystem-resolution check is introduced by this PR. A targeted fix is to use resolvingSymlinksInPath on the root and candidate, reject a resolved candidate outside the resolved root, and read the validated resolved URL.
  • Why this is likely a bug: The command is documented as a read-only view of a selected vault, so a note identifier that points outside that vault should not disclose another file. The code checks only the spelling of the path, while normal file reads follow symlinks; an attacker or accidental link can therefore bypass the intended boundary. Resolving both paths before containment checking is a small, local correction.
Relevant code

Sources/BANALCLI/BanalCLI.swift:302-317

private static func noteURL(for id: String, in configuration: VaultConfiguration) throws -> URL {
    guard NoteLanguage(pathExtension: (id as NSString).pathExtension) != nil else {
        throw CLIFailure("\"\(id)\" has no .md/.textile/.cook extension")
    }
    guard !id.hasPrefix("/") else {
        throw CLIFailure("note id must stay inside the vault")
    }
    let root = configuration.rootURL.standardizedFileURL.path
    let candidate = configuration.rootURL.appendingPathComponent(id).standardizedFileURL
    guard candidate.path.hasPrefix(root + "/") else {
        throw CLIFailure("note id must stay inside the vault")
    }
    guard FileManager.default.fileExists(atPath: candidate.path) else {
        throw CLIFailure("no note \"\(id)\" in \(root)")
    }
    return candidate
}
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**High severity — Symlinked notes can expose outside files**

**What failed:** The command should reject a note path that resolves outside the selected vault. Its path check accepts a link whose visible path is inside the vault, so show can return the contents of an outside file.

- **Impact:** A user can open a note link inside the vault and see the contents of a file outside it. This can expose private files whenever such a link is present.
- **Steps to reproduce:**
  1. Create a temporary vault directory and a separate readable Markdown file outside that directory.
  2. Create a symlink named linked.md inside the vault that points to the outside Markdown file.
  3. Run banal show linked.md --vault <vault-directory>.
  4. Observe that the command accepts the path and returns the outside file contents instead of rejecting the link.
- **Stub / mock content:** No stubs, mocks, or bypasses were applied for this test in the recorded run.
- **Code analysis:** BanalCLI.noteURL(for:in:) checks the extension and rejects lexical absolute paths at Sources/BANALCLI/BanalCLI.swift:302-308. It then builds a candidate with appendingPathComponent(id).standardizedFileURL and compares candidate.path with configuration.rootURL.standardizedFileURL.path using a string prefix at lines 309-312. standardization removes components such as '..' but does not resolve filesystem symlinks. The later fileExists check and returned URL at lines 314-317 still refer to the vault-local symlink path, and Foundation follows that symlink when show reads the URL. The PR diff adds the entire BanalCLI implementation, including these lines, so the missing filesystem-resolution check is introduced by this PR. A targeted fix is to use resolvingSymlinksInPath on the root and candidate, reject a resolved candidate outside the resolved root, and read the validated resolved URL.
- **Why this is likely a bug:** The command is documented as a read-only view of a selected vault, so a note identifier that points outside that vault should not disclose another file. The code checks only the spelling of the path, while normal file reads follow symlinks; an attacker or accidental link can therefore bypass the intended boundary. Resolving both paths before containment checking is a small, local correction.

**Relevant code:**

`Sources/BANALCLI/BanalCLI.swift:302-317`

~~~swift
private static func noteURL(for id: String, in configuration: VaultConfiguration) throws -> URL {
    guard NoteLanguage(pathExtension: (id as NSString).pathExtension) != nil else {
        throw CLIFailure("\"\(id)\" has no .md/.textile/.cook extension")
    }
    guard !id.hasPrefix("/") else {
        throw CLIFailure("note id must stay inside the vault")
    }
    let root = configuration.rootURL.standardizedFileURL.path
    let candidate = configuration.rootURL.appendingPathComponent(id).standardizedFileURL
    guard candidate.path.hasPrefix(root + "/") else {
        throw CLIFailure("note id must stay inside the vault")
    }
    guard FileManager.default.fileExists(atPath: candidate.path) else {
        throw CLIFailure("no note \"\(id)\" in \(root)")
    }
    return candidate
}
~~~

}
}

private static func vault(_ invocation: Invocation, out: (String) -> Void) throws -> Int32 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View All Evidence

Medium severity Read-only commands change vault files

What failed: The inspection commands are expected to leave every vault file unchanged, but opening the vault can create directories, write configuration, and seed Welcome.md.

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: Medium Medium severity
  • Impact: A user checking an existing vault can find new folders or files, including a configuration file or Welcome note, even though the command is read-only. This can undermine trust in the vault and may require cleanup, but no data loss is shown.
  • Steps to Reproduce:
    1. Prepare an existing vault with nested notes and custom configuration, and record every file's path, size, modification time, and content hash.
    2. Run banal vault, banal notes, banal show <id>, and banal doctor against that vault, including their JSON forms.
    3. Scan the vault again and compare the file list and file contents with the original snapshot.
    4. Observe that the inspection path can add .banal/config.json, assets, .banal, or Welcome.md when those items are missing.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The defect is established by the production source even though the native runtime could not be built in the Linux test container because the Apple-only UniformTypeIdentifiers module was unavailable. In the PR-added Sources/BANALCLI/BanalCLI.swift, vault() calls openStore(configuration) at lines 151-155, notes() calls openStore(resolveVault(...)) at lines 164-167, publish() calls it at lines 201-207, and doctor() calls it at lines 233-241. show() resolves the same vault at lines 182-191 and uses the shared note path. openStore() at lines 295-300 constructs NoteStore and immediately calls store.open(). Sources/BANALCore/NoteStore.swift:156-165 checks that the root exists, then unconditionally calls VaultBootstrap.prepare, loads configuration, reloads notes, and starts monitoring. Sources/BANALCore/VaultConfiguration.swift:149-176 makes that preparation mutating: it creates the assets and metadata directories, writes .banal/config.json when absent at lines 154-158, and writes Welcome.md when no top-level note exists at lines 160-175. The CLI's new inspection commands therefore violate the PR-added read-mostly contract for valid existing vaults. The smallest practical fix is to give read-only CLI operations a non-bootstrapping open path that validates and loads existing metadata without calling VaultBootstrap.prepare; keep bootstrap behavior for app initialization and explicitly mutating setup flows.
  • Why this is likely a bug: This is a real application defect rather than a setup failure: the source unconditionally invokes a function whose documented behavior is to create directories and files. The PR explicitly describes banal as read-mostly and says it never creates or changes notes, while its newly added command handlers all use the mutating initialization path. The Linux build limitation prevented observing the before-and-after snapshot, but it does not change the reachable production control flow. A targeted non-mutating read path for the inspection commands fixes the contract without changing the app's normal bootstrap behavior.
Relevant code

Sources/BANALCLI/BanalCLI.swift:151-167

private static func vault(_ invocation: Invocation, out: (String) -> Void) throws -> Int32 {
    let configuration = try resolveVault(invocation.vaultPath)
    let count = try MainActor.assumeIsolated {
        try openStore(configuration).notes.count
    }
    ...
}

private static func notes(_ invocation: Invocation, out: (String) -> Void) throws -> Int32 {
    let storeNotes = try MainActor.assumeIsolated { () -> [Note] in
        try openStore(resolveVault(invocation.vaultPath)).notes

Sources/BANALCLI/BanalCLI.swift:295-300

@MainActor
private static func openStore(_ configuration: VaultConfiguration) throws -> NoteStore {
    let store = NoteStore(configuration: configuration, monitor: nil)
    try store.open()
    return store
}

Sources/BANALCore/NoteStore.swift:156-165

public func open() throws {
    var isDirectory: ObjCBool = false
    if !fileManager.fileExists(atPath: configuration.rootURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue {
        throw NoteStoreError.vaultNotDirectory(configuration.rootURL)
    }
    try VaultBootstrap.prepare(configuration, fileManager: fileManager)
    configuration = VaultBootstrap.load(from: configuration.rootURL, fileManager: fileManager)
    rootMissing = false
    try reloadAll()
    startMonitor()
}

Sources/BANALCore/VaultConfiguration.swift:149-176

public static func prepare(_ configuration: VaultConfiguration, fileManager: FileManager = .default) throws -> URL {
    try fileManager.createDirectory(at: configuration.rootURL, withIntermediateDirectories: true)
    try fileManager.createDirectory(at: configuration.assetsURL, withIntermediateDirectories: true)
    try fileManager.createDirectory(at: configuration.metadataURL, withIntermediateDirectories: true)

    if !fileManager.fileExists(atPath: configuration.configURL.path) {
        ...
        try data.write(to: configuration.configURL, options: .atomic)
    }
    ...
    if !hasNote {
        ...
        try Data(document.utf8).write(to: welcomeURL, options: .atomic)
    }
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**Medium severity — Read-only commands change vault files**

**What failed:** The inspection commands are expected to leave every vault file unchanged, but opening the vault can create directories, write configuration, and seed `Welcome.md`.

- **Impact:** A user checking an existing vault can find new folders or files, including a configuration file or Welcome note, even though the command is read-only. This can undermine trust in the vault and may require cleanup, but no data loss is shown.
- **Steps to reproduce:**
  1. Prepare an existing vault with nested notes and custom configuration, and record every file's path, size, modification time, and content hash.
  2. Run `banal vault`, `banal notes`, `banal show <id>`, and `banal doctor` against that vault, including their JSON forms.
  3. Scan the vault again and compare the file list and file contents with the original snapshot.
  4. Observe that the inspection path can add `.banal/config.json`, `assets`, `.banal`, or `Welcome.md` when those items are missing.
- **Stub / mock content:** No stubs, mocks, or bypasses were applied for this test in the recorded run.
- **Code analysis:** The defect is established by the production source even though the native runtime could not be built in the Linux test container because the Apple-only `UniformTypeIdentifiers` module was unavailable. In the PR-added `Sources/BANALCLI/BanalCLI.swift`, `vault()` calls `openStore(configuration)` at lines 151-155, `notes()` calls `openStore(resolveVault(...))` at lines 164-167, `publish()` calls it at lines 201-207, and `doctor()` calls it at lines 233-241. `show()` resolves the same vault at lines 182-191 and uses the shared note path. `openStore()` at lines 295-300 constructs `NoteStore` and immediately calls `store.open()`. `Sources/BANALCore/NoteStore.swift:156-165` checks that the root exists, then unconditionally calls `VaultBootstrap.prepare`, loads configuration, reloads notes, and starts monitoring. `Sources/BANALCore/VaultConfiguration.swift:149-176` makes that preparation mutating: it creates the assets and metadata directories, writes `.banal/config.json` when absent at lines 154-158, and writes `Welcome.md` when no top-level note exists at lines 160-175. The CLI's new inspection commands therefore violate the PR-added read-mostly contract for valid existing vaults. The smallest practical fix is to give read-only CLI operations a non-bootstrapping open path that validates and loads existing metadata without calling `VaultBootstrap.prepare`; keep bootstrap behavior for app initialization and explicitly mutating setup flows.
- **Why this is likely a bug:** This is a real application defect rather than a setup failure: the source unconditionally invokes a function whose documented behavior is to create directories and files. The PR explicitly describes `banal` as read-mostly and says it never creates or changes notes, while its newly added command handlers all use the mutating initialization path. The Linux build limitation prevented observing the before-and-after snapshot, but it does not change the reachable production control flow. A targeted non-mutating read path for the inspection commands fixes the contract without changing the app's normal bootstrap behavior.

**Relevant code:**

`Sources/BANALCLI/BanalCLI.swift:151-167`

~~~swift
private static func vault(_ invocation: Invocation, out: (String) -> Void) throws -> Int32 {
    let configuration = try resolveVault(invocation.vaultPath)
    let count = try MainActor.assumeIsolated {
        try openStore(configuration).notes.count
    }
    ...
}

private static func notes(_ invocation: Invocation, out: (String) -> Void) throws -> Int32 {
    let storeNotes = try MainActor.assumeIsolated { () -> [Note] in
        try openStore(resolveVault(invocation.vaultPath)).notes
~~~

`Sources/BANALCLI/BanalCLI.swift:295-300`

~~~swift
@MainActor
private static func openStore(_ configuration: VaultConfiguration) throws -> NoteStore {
    let store = NoteStore(configuration: configuration, monitor: nil)
    try store.open()
    return store
}
~~~

`Sources/BANALCore/NoteStore.swift:156-165`

~~~swift
public func open() throws {
    var isDirectory: ObjCBool = false
    if !fileManager.fileExists(atPath: configuration.rootURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue {
        throw NoteStoreError.vaultNotDirectory(configuration.rootURL)
    }
    try VaultBootstrap.prepare(configuration, fileManager: fileManager)
    configuration = VaultBootstrap.load(from: configuration.rootURL, fileManager: fileManager)
    rootMissing = false
    try reloadAll()
    startMonitor()
}
~~~

`Sources/BANALCore/VaultConfiguration.swift:149-176`

~~~swift
public static func prepare(_ configuration: VaultConfiguration, fileManager: FileManager = .default) throws -> URL {
    try fileManager.createDirectory(at: configuration.rootURL, withIntermediateDirectories: true)
    try fileManager.createDirectory(at: configuration.assetsURL, withIntermediateDirectories: true)
    try fileManager.createDirectory(at: configuration.metadataURL, withIntermediateDirectories: true)

    if !fileManager.fileExists(atPath: configuration.configURL.path) {
        ...
        try data.write(to: configuration.configURL, options: .atomic)
    }
    ...
    if !hasNote {
        ...
        try Data(document.utf8).write(to: welcomeURL, options: .atomic)
    }
~~~

… from #205

Beau's parallel CLI implementation (#205) had four ideas worth more
than ours:

- doctor now distinguishes a configured-but-not-executable engine
  (fail: a config error) from an engine that was never configured
  (warn: the builtin path is a healthy choice).
- Exit code 64 when doctor finished with warnings — agents can tell
  degraded-but-working from broken without parsing output.
- notes gains --published; note JSON gains bytes (file size).
- publish refreshes security-scoped compiler bookmarks before
  resolving engines, matching the app's own courtesy.
Base automatically changed from fix/202-spaced-titles-boris-identity to main August 25, 2026 18:34
@drawmeanelephant
drawmeanelephant merged commit 9ec2baf into main Aug 25, 2026
3 checks passed
@drawmeanelephant
drawmeanelephant deleted the feat/204-agent-cli branch August 25, 2026 18:34
drawmeanelephant added a commit that referenced this pull request Aug 25, 2026
main picked up the agent CLI (#207) and the identity-contract fix
(#206) while this branch carried the AppleScript dictionary. Both
sides appended a section after Publish — Command line and Scripting
now coexist — and the STATUS verification-tooling exception appears
once, with both surfaces listed.
@itoqa

itoqa Bot commented Aug 25, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Report7e126bc197f75a: 19 test cases ran, 1 still failing ❌, 15 passing ✅, 3 additional findings ⚠️.

Diff Summary

Coverage spans identity validation and boundary cases, note selection and ordering, publishing outputs and source preservation, product separation, and clear error handling. Adversarial coverage also examined malformed or oversized names and overlapping publishing, with overall behavior broadly healthy aside from a publishing edge case involving name collisions near the length limit.

Merge with caution — a medium-severity failure attributable to this PR remains in the publishing path, where collision suffixes can produce invalid names and unusable output. Separate high-severity concurrency findings were identified but are not attributable to this PR and are caveats for later.

Tests run by Ito

View full run

Result State Severity Type Description
❌->❌ Still Failing Medium severity Rev The second colliding name receives a suffix after the complete sanitized stem is built, but the resulting ID is not checked against Boris's 255-byte limit. A 254-byte shared stem becomes a 256-byte second ID after '-2' is appended.
Passing General The identity check accepts an ID that is exactly 255 UTF-8 bytes long and rejects IDs that are 256 bytes long. It also rejects 128 accented characters because they use 256 UTF-8 bytes.
Passing General IDs with empty, dot, or dot-dot segments are rejected and cannot become valid through normalization. The full command-line flow was unavailable on Linux because the package needs an Apple-only framework, but the actual identity validator was exercised directly.
Passing General The collision check could not run because the Linux environment cannot compile the Apple-only application framework. Source review confirms each note keeps one assigned ID across its page metadata and staged source path, while the local file extension remains attached to the path.
Passing General Verified acceptable by independent adversarial review: the observed behavior is intended and documented in this codebase. Review notes: The literal observation that a failed compile leaves .banal/stage on disk is statically true, but the repository treats that directory as persistent, disposable publisher scratch rather than a temporary resource that must be removed when publish returns: successful publishing also leaves it available and tests inspect it. The claimed stale-content hazard is contradicted by the sole staging ent…
Passing General The compiler check could not run because this Mac-only package cannot compile in the Linux test environment. Source review shows that a configured non-executable path is reported as a failure, and publishing labels the built-in compiler clearly instead of hiding the fallback.
Passing General The mixed-note check was blocked by the Linux toolchain before publishing could start. Source code and existing tests show that Markdown is published, unsupported Cooklang is reported separately, and the result keeps those outcomes together without changing the source notes.
Passing Command The command could not run in the Linux test container because this package requires macOS 14 and Apple frameworks. Source review confirms that the command accepts the vault and JSON flags, loads the selected vault, and produces a sorted note listing.
Passing Doctor The health check could not run because the Linux test environment could not build this macOS app. The source and test contract support a clean JSON report when run on the required macOS toolchain.
Passing Identity Publishing notes with the same name gives them stable names such as hello and hello-2. Their staged source files keep the .md and .cook endings needed for each language.
Passing Identity The valid identity guides/welcome was accepted as expected. The identity contains 14 UTF-8 bytes and passes the contract check.
Passing Launch The package keeps the existing GUI product separate from the new command-line product. Source checks confirmed that each executable uses its intended entry point; native launch checks were unavailable because the test environment is Linux and does not provide macOS frameworks.
Passing Notes The published-notes behavior is implemented and covered by a focused regression test. The command could not be run in this Linux container because the native Swift toolchain was missing required libraries and targets macOS.
Passing Publish Publishing selects marked notes, creates the site files and publish metadata, and leaves the original note text unchanged. The native command could not run in the Linux workspace, but source inspection and existing publisher tests cover this behavior.
Passing Show The note-reading path supports returning the saved UTF-8 source and keeping its trailing newline. The command could not run in the Linux test container because the Swift toolchain lacks a required module, so no runtime output was available to compare.
Passing Vault The command could not start in the Linux test container because this app needs a macOS-only framework. Source review confirms the command uses the selected folder and reports its path and note count as required.
⏸️ Skipped Doctor The doctor command is designed to report a failed vault check when the vault cannot be opened and a failed contract check when a published note has an invalid identity. Either problem makes the command return exit code 1; the native run could not reach this code because the Linux test environment was missing Swift runtime dependencies.
⏸️ Skipped Product The existing desktop app launcher still points to the native app, and the new command-line tool has its own entry point. The products could not be launched in this Linux environment because it does not provide the required macOS frameworks.
⏸️ Skipped Publish The draft-only vault could not be published because the command could not start in the Linux test environment. Source code confirms that the app would reject the vault with a clear no-published-notes message instead of claiming success.
⏸️ Skipped Rev A failed publish reports the real process status and diagnostic message, returns a failure code, and does not print a success message. The recorded run could not reach the publish command because the Linux environment cannot compile this macOS-only application, but source inspection confirms the intended failure behavior.
⏸️ Skipped Show The command could not start in the Linux test environment because the project failed to compile. The source includes checks that reject absolute paths and paths that leave the selected vault.
⚠️ Additional Finding High severity General The concurrent publisher check could not launch in the Linux test environment because Swift could not import an Apple-only framework, and no prebuilt executable was available. Source review still shows that overlapping publish jobs can remove and rewrite the same output directories while the other job is using them.
⚠️ Additional Finding High severity General The concurrent commands could not reach publishing in the Linux test container because the macOS-only Swift package failed to build on missing UniformTypeIdentifiers. Source inspection still shows that concurrent runs are not isolated and can overwrite one another's staging and publish files.
⚠️ Additional Finding Medium severity Rev The publish path keeps a 256-byte sanitized name as the published ID instead of rejecting it or reducing it to a valid ID. It then uses that ID to build the staged source filename.
Findings dismissed by reviewer

Below are prior failures a reviewer explicitly dismissed. They were not retested and are not counted as outstanding failures:

Type Test Reason
Rev Read-only commands change vault files Dismissed because Deferred in favor of BF-BOUND-3, which directly exercises traversal and normalization bypasses.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟠 Concurrent publishing can overwrite results
  • Severity: High High severity
  • Description: The concurrent publisher check could not launch in the Linux test environment because Swift could not import an Apple-only framework, and no prebuilt executable was available. Source review still shows that overlapping publish jobs can remove and rewrite the same output directories while the other job is using them.
  • Impact: When two publishes run for the same vault, the live site may contain mixed or missing pages and feed files. The incorrect content can remain until the vault is published again.
  • Steps to Reproduce:
    1. Prepare a vault with at least one published note.
    2. Start two publish commands at the same time and point both at the same vault.
    3. Compare the generated pages, source files, mappings, and feed with the expected output from either publish job.
    4. Repeat the test several times, because the failure depends on which job deletes or writes the shared directories first.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: Sources/BANALPublisher/BorisAdapter.swift:130-147 takes configuration.stagingDirectory directly, removes that directory if it exists at lines 140-143, and recreates it for the current publish at lines 144-147. It then writes the current job's Markdown files into that shared tree at lines 160-163. Sources/BANALPublisher/BANALPublisher.swift:57-68 passes the same configuration.stagingDirectory and configuration.artifactDirectory into the compiler after staging, so a second publish can remove or replace the first job's inputs between those operations. Sources/BANALPublisher/SiteCompiler.swift:27-37 removes and recreates artifactDirectory before writing pages, while the Boris compiler path removes and copies the artifact tree again at lines 154-157; SiteHTML.write at lines 85-88 and BANALPublisher.swift:80-87 then write additional HTML and feed files into that mutable shared tree. BANALPublisher.publish has no lock, run-specific directory, or explicit busy failure around these operations. The smallest practical fix is to serialize publishes that target the same configuration directories, or allocate a unique staging and artifact directory per publish and publish the completed tree only after compilation succeeds.
Evidence Package
🟠 Overlapping publishes can overwrite each other
  • Severity: High High severity
  • Description: The concurrent commands could not reach publishing in the Linux test container because the macOS-only Swift package failed to build on missing UniformTypeIdentifiers. Source inspection still shows that concurrent runs are not isolated and can overwrite one another's staging and publish files.
  • Impact: When two publishes run at once, one release can contain the other run's files or a partial set of files. This can put incorrect site content in production and may require another publish to repair.
  • Steps to Reproduce:
    1. Prepare a vault with at least one published note and a usable local compiler.
    2. Start two banal publish --json --vault <vault> commands at nearly the same time.
    3. Compare both commands' reported files with the final contents of .banal/stage and .publish.
    4. Check whether either output is missing files or contains pages generated from the other command's input.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: PublishConfiguration.default(for:) assigns every invocation the vault's fixed publishURL (.publish) as artifactDirectory and metadataURL/appendingPathComponent("stage") (.banal/stage) as stagingDirectory in Sources/BANALPublisher/PublishModels.swift:34-42. BANALPublisher.publish in Sources/BANALPublisher/BANALPublisher.swift:57-68 passes those same paths to BorisAdapter.stage and the compiler, then writes additional HTML, RSS, and Wrangler files into the same artifact directory at lines 70-95. BorisAdapter.stage unconditionally removes the shared staging directory at lines 140-143 before rebuilding it, so one invocation can delete files while the other is reading or writing them. BuiltinSiteCompiler.compile likewise removes and recreates the shared artifact directory at Sources/BANALPublisher/SiteCompiler.swift:33-37, and BorisCLICompiler.compile removes its shared staging-relative dist directory at lines 119-122 before invoking Boris. These operations have no lock, per-run directory, atomic directory swap, or consistency check. The smallest practical fix is to serialize publishes for a vault with a lock around staging and compilation, or allocate a unique staging directory per invocation and atomically publish a completed artifact tree; the former is the narrower fix for this race.
Evidence Package
🟡 Long note names create invalid published IDs
  • Severity: Medium Medium severity
  • Description: The publish path keeps a 256-byte sanitized name as the published ID instead of rejecting it or reducing it to a valid ID. It then uses that ID to build the staged source filename.
  • Impact: Users with note names over the allowed length may get an invalid published ID and file path, so publishing that note can fail or produce unusable output. Renaming the note to a shorter name avoids the problem.
  • Steps to Reproduce:
    1. Create a published Markdown note whose extension-free name is 256 ASCII characters long.
    2. Run the local publish command for that vault.
    3. Inspect the assigned published ID and the staged Markdown file path.
    4. Compare the ID length with the 255-byte Boris identity limit.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: BorisIdentity.isValid in Sources/BANALPublisher/BorisIdentity.swift:26-34 rejects empty IDs and IDs whose UTF-8 representation exceeds maximumByteCount, which is 255 at lines 20-21. However, BorisAdapter.entityIDs in Sources/BANALPublisher/BorisAdapter.swift:29-52 assigns BorisIdentity.sanitizedEntityID(from: NoteIdentity.droppingLanguageExtension(note.id)) directly at line 38. The only fallback is for an empty candidate at lines 39-41, and collision handling at lines 42-48 only appends a numeric suffix. There is no call to BorisIdentity.isValid and no byte-aware truncation or rejection. For a 256-character ASCII stem, sanitization preserves all 256 bytes, so the value stored in assigned at line 49 violates the contract. BorisAdapter.sourceRelativePath at lines 55-59 appends the language extension to that unbounded ID, and stage writes page.source to the resulting destination at lines 160-163. This means the invalid identity crosses into both generated frontmatter and the staged filesystem path. The smallest practical fix is to validate each candidate before inserting it into assigned and either throw a clear publish error or generate a byte-safe candidate while reserving bytes for any collision suffix; the fix belongs at the assignment boundary rather than in the downstream compiler.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

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.

feat proposal: thin agent CLI over BANALCore/BANALPublisher (vault/notes/show/publish) to stop UI-scripting burn

1 participant