diff --git a/.github/workflows/onnx.yml b/.github/workflows/onnx.yml index d1549d4f4..8af2a9179 100644 --- a/.github/workflows/onnx.yml +++ b/.github/workflows/onnx.yml @@ -17,6 +17,16 @@ on: permissions: {} jobs: + security: + if: github.event_name == 'workflow_dispatch' && inputs.publish + permissions: + contents: read + actions: read + security-events: write + uses: WireCatLabs/cli-core/.github/workflows/release-checks.yml@1183bfb970330989ad8acec917b14b7e6187de15 + with: + languages: '["actions","javascript-typescript","c-cpp"]' + pack: runs-on: ubuntu-latest permissions: @@ -71,7 +81,7 @@ jobs: run: bun probe.mjs publish: - needs: probe + needs: [probe, security] if: github.event_name == 'workflow_dispatch' && inputs.publish && github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1615b050..d6079ace2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,14 @@ concurrency: jobs: checks: - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || inputs.dry_run permissions: contents: read - uses: WireCatLabs/cli-core/.github/workflows/release-checks.yml@b36a30fb6037b457001bd2db860949f64512ecdd # v0.17.3 + actions: read + security-events: write + uses: WireCatLabs/cli-core/.github/workflows/release-checks.yml@1183bfb970330989ad8acec917b14b7e6187de15 + with: + languages: '["actions","javascript-typescript","python","c-cpp","java-kotlin"]' build: needs: checks diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml index 2dee8516d..5ae571762 100644 --- a/.github/workflows/sqlite.yml +++ b/.github/workflows/sqlite.yml @@ -22,6 +22,16 @@ env: GLIBC_FLOOR: "2.28" jobs: + security: + if: github.event_name == 'workflow_dispatch' && inputs.publish + permissions: + contents: read + actions: read + security-events: write + uses: WireCatLabs/cli-core/.github/workflows/release-checks.yml@1183bfb970330989ad8acec917b14b7e6187de15 + with: + languages: '["actions","javascript-typescript","c-cpp"]' + linux: strategy: fail-fast: false @@ -150,7 +160,7 @@ jobs: LD_LIBRARY_PATH=/w/package/lib/${{ matrix.target }} node packages/sqlite/probe.mjs - ${{ env.SQLITE_VERSION }}' publish: - needs: [macos, distributions] + needs: [macos, distributions, security] if: github.event_name == 'workflow_dispatch' && inputs.publish && github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: npm diff --git a/CHANGELOG.md b/CHANGELOG.md index b86b417f9..99155f0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ break callers, and says how under "Changed — may break callers". `pnpm docs:ch ### Fixed +- PDF extraction and page previews accept long documents; extraction follows caller cancellation instead of a separate page/time cutoff. Local Ogg Opus transcription accepts recordings beyond ten minutes while retaining format validation. +- Attachment guards allow ordinary hidden developer folders while protecting known credentials, application state and exact store files. Markdown exports preserve message formatting; MCP writes preserve original Unicode and registry checks retain their normal default. Reply-template previews and model prompts use normal interpolated values. Configured embedding gateways use normal redirect handling. + - Under Bun a missing row read as `null` instead of `undefined`, so the store took it for a found row: linking a meeting to an event that does not exist succeeded. The Bun driver now answers `undefined`, as the Node one does. diff --git a/docs/dev/STANDARD.md b/docs/dev/STANDARD.md index 28d726003..36c1dc680 100644 --- a/docs/dev/STANDARD.md +++ b/docs/dev/STANDARD.md @@ -309,7 +309,7 @@ its meaning, and this table is regenerated from it. **Bold** marks a clash still | `--all` | | every row, no paging. **chats requests accept and decline take every pending request instead — Telegram counts them against its hourly limit first; MAX has no way to answer them all at once and refuses it, naming chats requests list — so it differs on purpose (Help text rule 4)** | | `attachments list`, `chats list`, `chats members list` (planned), `chats requests accept`, `chats requests decline`, `contacts list`, `inbox` (planned), `messages download`, `review` (planned), `store export`, `store fetch` | | `--all-bots` | | also read every other bot's copy on this machine that readOtherBots allows | | `bot contacts show`, `bot messages between`, `bot search messages` | | `--all-can-pin` | `` | every member may pin messages | | `chats update` | -| `--allow-any-file` | | send a --file even from a hidden folder, ~/.ssh or the tool's own folders | | `bot messages send`, `messages send` | +| `--allow-any-file` | | send a --file even from credential folders or the tool's own folders | | `bot messages send`, `messages send` | | `--allow-chats` | `` | replace allowed chat ids, comma-separated; empty clears | | `replies audience` | | `--allow-dangerous` | | go ahead without the question an ask level puts before a deletion | | `bot chats moderate`, `bot mcp`, `bot mcp config`, `bot messages delete`, `chats clear` (planned), `chats delete` (planned), `chats moderate` (planned), `mcp` (planned), `mcp config` (planned), `mcp doctor` (planned), `mcp setup` (planned), `messages delete`, `store clear` | | `--allow-delete` | | offer the tool that deletes messages for you only; it cannot be undone. **retired access flag: accepted with a warning, grants no permissions in either CLI** | | `bot mcp`, `bot mcp config`, `mcp`, `mcp config`, `mcp doctor` (planned), `mcp setup` (planned) | diff --git a/docs/replies.md b/docs/replies.md index 2b6674d14..bd5624e8d 100644 --- a/docs/replies.md +++ b/docs/replies.md @@ -67,10 +67,8 @@ Thanks, {{ sender.firstName | default: "there" }}. {% ai %}Write a brief acknowledgement; I will answer tomorrow.{% else %}I will answer tomorrow.{% endai %} ``` -The block's owner-written body is the instruction. Interpolated names, titles and other values -go separately as untrusted template data, referenced at their insertion points. Incoming message -text is separate untrusted data too. JSON previews show those values in each block’s -`templateValues` beside the instruction’s insertion references. Model output replaces only the block and is never parsed again as Liquid. Text outside the +The block's rendered body is the instruction, and incoming message text goes separately as untrusted +data. Model output replaces only the block and is never parsed again as Liquid. Text outside the block remains the owner's text with its normal substitutions. When a provider is missing, a call fails or consent is absent, the else branch is used. Without one the reply is skipped with a reason. Nested AI blocks are refused. Per rendering: at most four model calls, 512 output tokens per call, diff --git a/docs/search/ai-providers.md b/docs/search/ai-providers.md index e9223765d..19cad433c 100644 --- a/docs/search/ai-providers.md +++ b/docs/search/ai-providers.md @@ -87,4 +87,4 @@ The runner reads the same shipped `link-conversations` skill as your agent, adds Anthropic uses the official TypeScript SDK and [Messages streaming](https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/typescript); the runner reads the final message and checks its stop reason. The OpenAI-compatible adapter checks the first choice's finish reason and reported token usage. Both adapters are tested with local fake servers; there are no real provider calls in the test suite. Embedding endpoints must accept requests at the configured URL directly. Set `embeddingBaseUrl` to -the API base serving `/embeddings` directly; credential-bearing embedding requests refuse redirects. +the API base serving `/embeddings`; configured gateways use normal HTTP redirect handling. diff --git a/parity.json b/parity.json index f332e41b3..378c6b941 100644 --- a/parity.json +++ b/parity.json @@ -42,7 +42,7 @@ "meaning": "every member may pin messages" }, "--allow-any-file": { - "meaning": "send a --file even from a hidden folder, ~/.ssh or the tool's own folders" + "meaning": "send a --file even from credential folders or the tool's own folders" }, "--allow-chats": { "meaning": "replace allowed chat ids, comma-separated; empty clears", diff --git a/socket.yml b/socket.yml new file mode 100644 index 000000000..b5c0fa2e2 --- /dev/null +++ b/socket.yml @@ -0,0 +1,3 @@ +version: 2 +githubApp: + enabled: false diff --git a/src/attachments/directory.test.ts b/src/attachments/directory.test.ts index 4996383e4..802e3bf89 100644 --- a/src/attachments/directory.test.ts +++ b/src/attachments/directory.test.ts @@ -70,7 +70,7 @@ describe("directory attachment matching", () => { } }) - it("refuses hidden and application folders through both typed paths and directory aliases", async () => { + it("refuses credential and application folders through both typed paths and directory aliases", async () => { const { store, folder } = await fixture() const env = { ...process.env, @@ -92,8 +92,10 @@ describe("directory attachment matching", () => { for (const path of [directory, alias]) await expect(directoryPaths(store, owner, "1", path, app, env)).rejects.toThrow("cannot read") } - writeFileSync(join(folder, ".private.txt"), "synthetic") - await expect(directoryPaths(store, owner, "1", folder, app, env)).rejects.toThrow("cannot read hidden files") + const allowed = join(folder, ".worktrees") + mkdirSync(allowed) + writeFileSync(join(allowed, ".private.txt"), "synthetic") + await expect(directoryPaths(store, owner, "1", allowed, app, env)).resolves.toBeInstanceOf(Map) } finally { await store.close() } diff --git a/src/attachments/directory.ts b/src/attachments/directory.ts index 8ac15b851..598c8dd0a 100644 --- a/src/attachments/directory.ts +++ b/src/attachments/directory.ts @@ -19,7 +19,7 @@ export const directoryPaths = async ( if (refusedPlace(directory, app, env)) throw new CliError( "validation_error", - "--from-dir cannot read hidden folders, the CLI's own folders or the message store", + "--from-dir cannot read credential folders, the CLI's own folders or the message store", ) const root = await realpath(directory).catch(() => { throw new CliError("validation_error", "--from-dir must name an existing directory") @@ -39,7 +39,7 @@ export const directoryPaths = async ( if (refusedPlace(canonical, app, env)) throw new CliError( "validation_error", - "--from-dir cannot read hidden files, the CLI's own files or the message store", + "--from-dir cannot read credential files, the CLI's own files or the message store", ) const inside = relative(root, canonical) if (inside === ".." || inside.startsWith(`..${sep}`)) diff --git a/src/attachments/extract.test.ts b/src/attachments/extract.test.ts index 5c00a24c5..edc39088d 100644 --- a/src/attachments/extract.test.ts +++ b/src/attachments/extract.test.ts @@ -39,54 +39,48 @@ describe("reading the text layer of a file", () => { expect(extractRawText).not.toHaveBeenCalled() }) - it("refuses oversized PDF page counts before reading text and closes the document", async () => { - const destroy = vi.fn(async () => {}) - const read = vi.fn() - const load: LoadEngine = async () => ({ - getDocumentProxy: async () => ({ numPages: 100_000, loadingTask: { destroy } }), - extractText: read, - }) - expect(await extractText(pdf(), hint("large.pdf"), load)).toEqual({ status: "too-large" }) - expect(read).not.toHaveBeenCalled() - expect(destroy).toHaveBeenCalledOnce() - }) - - it("bounds stuck PDF text extraction and cancels a pending document load", async () => { + it("extracts a long PDF and closes its document without a fixed page/time cutoff", async () => { vi.useFakeTimers() try { const destroy = vi.fn(async () => {}) - const read = vi.fn(() => new Promise(() => {})) const load: LoadEngine = async () => ({ - getDocumentProxy: async () => ({ numPages: 1, loadingTask: { destroy } }), - extractText: read, + getDocumentProxy: async () => ({ numPages: 100, loadingTask: { destroy } }), + extractText: async () => { + await new Promise((resolve) => setTimeout(resolve, 31_000)) + return { text: ["Synthetic long document"], totalPages: 100 } + }, }) - const pending = extractText(pdf(), hint("stuck.pdf"), load) - await vi.advanceTimersByTimeAsync(30_000) - expect(await pending).toMatchObject({ status: "unreadable" }) + const pending = extractText(pdf(), hint("long.pdf"), load) + await vi.advanceTimersByTimeAsync(31_000) + expect(await pending).toMatchObject({ status: "extracted", pages: 100 }) expect(destroy).toHaveBeenCalledOnce() - const abort = new AbortController() - let finish = (_value: unknown) => {} - const loading: LoadEngine = async () => ({ - getDocumentProxy: () => - new Promise((resolve) => { - finish = resolve - }), - extractText: read, - }) - const cancelled = extractText(pdf(), hint("cancelled.pdf"), loading, abort.signal) - const rejected = expect(cancelled).rejects.toMatchObject({ code: "cancelled" }) - await vi.advanceTimersByTimeAsync(0) - abort.abort() - await rejected - finish({ numPages: 1, loadingTask: { destroy } }) - await vi.advanceTimersByTimeAsync(0) - expect(destroy).toHaveBeenCalledTimes(2) - expect(read).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) } finally { vi.useRealTimers() } }) + + it("cancels a pending PDF load and releases a late document", async () => { + const destroy = vi.fn(async () => {}) + const read = vi.fn() + const abort = new AbortController() + let finish = (_value: unknown) => {} + const loading: LoadEngine = async () => ({ + getDocumentProxy: () => + new Promise((resolve) => { + finish = resolve + }), + extractText: read, + }) + const pending = extractText(pdf(), hint("cancelled.pdf"), loading, abort.signal) + const rejected = expect(pending).rejects.toMatchObject({ code: "cancelled" }) + await new Promise((resolve) => setTimeout(resolve, 0)) + abort.abort() + await rejected + finish({ numPages: 1, loadingTask: { destroy } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(destroy).toHaveBeenCalledOnce() + expect(read).not.toHaveBeenCalled() + }) it("preserves spreadsheet cell addresses and PDF page spans", async () => { const workbook = new ExcelJS.Workbook() const sheet = workbook.addWorksheet("Synthetic Budget") diff --git a/src/attachments/extract.ts b/src/attachments/extract.ts index 356b2fc6c..948854c85 100644 --- a/src/attachments/extract.ts +++ b/src/attachments/extract.ts @@ -142,10 +142,7 @@ export const extractText = async ( if (kind) return readDocument(bytes, kind, signal) if (startsWith(bytes, "%PDF-")) { return withEngine(load, "unpdf", async (unpdf, extractor) => { - const deadline = new AbortController() - const timeout = setTimeout(() => deadline.abort(), 30_000) - timeout.unref() - const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal + const bounded = signal ?? new AbortController().signal let pdf: PdfDocument | undefined let closed = false const close = () => { @@ -158,7 +155,7 @@ export const extractText = async ( const aborted = new Promise((_, reject) => { abort = () => { close() - reject(new CliError(signal?.aborted ? "cancelled" : "timeout", "PDF text extraction stopped")) + reject(new CliError("cancelled", "PDF text extraction stopped")) } bounded.addEventListener("abort", abort, { once: true }) if (bounded.aborted) abort() @@ -175,7 +172,7 @@ export const extractText = async ( close() bounded.throwIfAborted() } - if (!Number.isSafeInteger(pdf.numPages) || pdf.numPages < 1 || pdf.numPages > 20) throw new ReaderLimit() + if (!Number.isSafeInteger(pdf.numPages) || pdf.numPages < 1) throw new ReaderLimit() return unpdf.extractText(pdf, { mergePages: false }) } const result = await Promise.race([reading(), aborted]) @@ -198,7 +195,6 @@ export const extractText = async ( ...(text.length > MAX_TEXT_CHARS ? { truncated: true } : {}), } } finally { - clearTimeout(timeout) bounded.removeEventListener("abort", abort) close() } diff --git a/src/attachments/pdf-preview.test.ts b/src/attachments/pdf-preview.test.ts index f019ec1c8..62a0cc25b 100644 --- a/src/attachments/pdf-preview.test.ts +++ b/src/attachments/pdf-preview.test.ts @@ -43,10 +43,14 @@ describe("PDF page previews for remote agents", () => { expect(fixture.destroy).toHaveBeenCalledTimes(1) expect(fixture.cleanup).toHaveBeenCalledTimes(1) }) + it("previews a requested page in a long document", async () => { + const fixture = fake({ pages: 100 }) + expect(await pdfPreview(pdf(), 99, undefined, fixture.load)).toMatchObject({ page: 99, pageCount: 100 }) + }) it("rejects invalid pages, page counts and dimensions before rendering", async () => { for (const page of [0, -1, 1.5, Infinity]) await expect(pdfPreview(pdf(), page)).rejects.toMatchObject({ code: "validation_error" }) - for (const pages of [0, 21, 1.5]) { + for (const pages of [0, 1.5]) { const fixture = fake({ pages }) await expect(pdfPreview(pdf(), 1, undefined, fixture.load)).rejects.toMatchObject({ code: "validation_error" }) expect(fixture.destroy).toHaveBeenCalledTimes(1) diff --git a/src/attachments/pdf-preview.ts b/src/attachments/pdf-preview.ts index ca8f817e7..8dae7bd03 100644 --- a/src/attachments/pdf-preview.ts +++ b/src/attachments/pdf-preview.ts @@ -61,8 +61,8 @@ export const pdfPreview = async ( }) signal?.addEventListener("abort", close, { once: true }) cancelled() - if (!Number.isSafeInteger(document.numPages) || document.numPages < 1 || document.numPages > 20) - throw new CliError("validation_error", "PDF previews support documents with 1–20 pages") + if (!Number.isSafeInteger(document.numPages) || document.numPages < 1) + throw new CliError("validation_error", "PDF page count must be a positive integer") if (page > document.numPages) throw new CliError("validation_error", "PDF page is past the end of the document") const selected = await document.getPage(page) let image: Buffer diff --git a/src/cli/bot/messages.ts b/src/cli/bot/messages.ts index 36f29f5f8..e2757ae64 100644 --- a/src/cli/bot/messages.ts +++ b/src/cli/bot/messages.ts @@ -81,7 +81,7 @@ const sendCommand = (bot: BotMessenger): Command => .option("--photo ", "attach a .jpg, .png or .webp as a photo; the text becomes its caption") .option("--as-file", "send the --file as a file to download, a video included") .option("--voice ", "send an Ogg Opus file as a voice message, alone, with no text") - .option("--allow-any-file", "send a file even from a hidden folder, ~/.ssh or this CLI's own folders") + .option("--allow-any-file", "send a file even from credential folders or this CLI's own folders") .action(async function (this: Command, chat: string, text: string | undefined) { const context = botContext(this, bot) const options = this.opts<{ diff --git a/src/cli/messenger/messages-send-command.ts b/src/cli/messenger/messages-send-command.ts index a8a933d42..3cf25f851 100644 --- a/src/cli/messenger/messages-send-command.ts +++ b/src/cli/messenger/messages-send-command.ts @@ -34,7 +34,7 @@ export const sendCommand = (messenger: Messenger): Command => { .option("--photo ", "attach a .jpg, .png or .webp as a photo; the text becomes its caption") .option("--as-file", "send the --file as a file to download, a video included") .option("--voice ", "send an Ogg Opus file as a voice message, alone, with no text") - .option("--allow-any-file", "send a file even from a hidden folder, ~/.ssh or this CLI's own folders") + .option("--allow-any-file", "send a file even from credential folders or this CLI's own folders") .option( "--at-time