Safe macro scripting: preservation → interpreter → capability broker → signatures → network - #36
Merged
Merged
Conversation
Defines the security-first design for macro support: disabled-by-default trust model keyed on a local trust store, capability-gated execution through a pure tree-walking interpreter (no JIT, iOS-compatible), a permanent 'never' list of refused features (process exec, FFI, COM/UNO, path-addressed file I/O, p-code, XLM), payload preservation in the provenance layer (fixing today's silent macro stripping on save), and an 8-phase implementation plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds MacroPayload to the doc-model provenance layer (DocumentSource.macros) with an order-independent, content-addressed payload hash for the future trust store. Teaches DOCX and XLSX import to collect vbaProject.bin (+ Word's vbaData.xml) into the payload, and export to re-emit it verbatim only for macro-enabled kinds (.docm/.dotm/.xlsm), stripping it for plain .docx/.xlsx to match Office extension semantics. VBA bytes are never parsed or executed. - loki-doc-model: io::macros (MacroPayload/PreservedPart/MacroPayloadKind) - loki-ooxml: shared crate::vba collect/emit; DocxKind macro variants + DocxMacroEnabledExport/TemplateExport; XlsxImport::run + XlsxImportResult.macros + XlsxExport::export_with_macros - Relocated cell_ref_to_coord to import_worksheet.rs (file-ceiling) - Round-trip tests: byte-identical project preservation, stable payload hash, plain-export stripping Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Teaches OdfPackage to collect the Basic/ and Scripts/ subtrees (manifest- driven, preserving each entry's declared media type and directory entries) into a MacroPayload (kind OdfBasic) on document.source.macros. ODT export and ODS export re-emit the libraries verbatim plus their manifest file-entries via a shared script_write helper. Scripts are never parsed or executed. - loki-odf: package_scripts collector; OdfPackage.macros; script_write shared re-emit helper; OdtImporter/OdtExport wired; OdsImport::run + OdsImportResult.macros + OdsExport::export_with_macros - Round-trip test: byte-identical StarBasic module + stable payload hash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
loki-convert now surfaces a 'macros dropped' warning when a source's VBA or StarBasic payload cannot be carried into the chosen target format (spec §3.5), and threads the payload through the identity ODS->ODS path so same-family spreadsheet macros are preserved rather than dropped. import_sheet uses the new XlsxImport::run / OdsImport::run to retrieve the payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
…itor Adds the passive, non-modal AtInfobar component (appthere-ui) and the macros Fluent domain (macros.ftl, registered in loki-i18n DOMAINS). loki-text shows 'This document contains macros. Macros are disabled.' when an opened document carries a preserved payload (editor_macro_notice reads document.source.macros, which lives outside the Loro CRDT). No enable/execute action exists in Phase 1 by design. Also documents the whole Phase 1 surface in fidelity-status.md §12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Backticks StarBasic in doc comments (clippy::doc_markdown), applies cargo fmt across the new macro code, and records the whole Phase 1 surface in docs/fidelity-status.md §12. All touched crates pass cargo test, clippy -D warnings, and the license/ceiling/unsafe CI gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
New L1 crate loki-basic: the pure tree-walking BASIC interpreter core (macro spec Phase 2). No JIT, no I/O deps, forbid(unsafe_code) — the interpreter has zero ambient authority (Host trait seam). This commit lands the foundation: - Dialect flag (Vba/StarBasic), typed errors (BasicError/RuntimeError with VBA Err.Number; feature-refusals and fuel/cancel stops are untrappable), Host trait + FuelVerdict + NullHost/FuelBudget (fuel metering, spec §8) - Lexer: line-oriented, case-insensitive; numbers (int/float/hex/oct/exponent), strings with "" escaping, #date# literals, operators, statement separators, line continuations, comments, type-suffix handling; 21 tests - Registered in workspace members + dependency-direction gate (L1 leaf) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Full AST (expressions, statements, declarations, module/procedure structure) and a recursive-descent parser over the lexer, shared by both dialects: - Pratt expression parser with VBA precedence (^ binds tighter than unary minus; concat above comparison; And/Or/Xor/Eqv/Imp/Mod/Is/Like keywords) - Statements: Dim/ReDim/Const, Let/Set assignment, bare + Call + named-arg calls, If (block + single-line + ElseIf), For/For Each, Do/Loop (pre/post While/Until), While/Wend, Select Case (values/ranges/Is), With + leading-dot, Exit, GoTo/labels, On Error, Resume, Error, Stop/End - Declarations: Sub/Function/Property with ByVal/ByRef/Optional/ParamArray/ defaults, Type, Enum, module Const/Dim, Option Base/Explicit/Compare, Attribute VB_Name; Declare (FFI) captured for runtime refusal (spec §7) - 17 parser integration tests + lexer split (scan.rs) to hold the ceiling Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
The dynamic Value type (Empty/Null/Bool/Int/Long/Double/Str/Date/Array) with VBA/StarBasic semantics: - Coercions: CBool/CDbl/CStr/CLng with banker's rounding, Bool as -1/0, strict numeric-string parsing - Arithmetic: numeric promotion (Integer<Long<Double), overflow raises error 6 (no silent widening), / always Double, \ and Mod truncate, ^ Double, the + string-concat vs numeric overload - Null propagation (& treats Null as ), comparison (Option Compare Text), logical/bitwise And/Or/Xor/Eqv/Imp/Not, and a Like matcher (?/*/#/[a-z]/[!…]) - Value-typed arrays with multi-dim row-major indexing, LBound/UBound, and a 16M-element cap (spec §8 memory guard) - 19 operator tests + coerce unit tests; crate-level allows for intentional numeric casts and exact-zero float guards Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
The evaluator that runs parsed modules against a Host, fuel-metered end to end: - Interp: indexes procedures/consts/enums/module vars; call() entry; per-step fuel charge (loops charge per iteration so empty infinite loops still stop); 256-deep call-stack cap - Frames on the Rust stack (env.rs) so &mut self + &mut Frame coexist; case- insensitive vars; Err object + On Error handler state + resume point - Expressions (eval.rs): literals, vars/consts/globals, zero-arg function auto-call, arithmetic, array indexing; Member/New/With deferred to Object phase - Statements (exec.rs): label/GoTo/On Error/Resume body loop, assignment (var + array element), Dim/ReDim(+Preserve 1-D), Const, bare/Call/Debug.Print - Control flow (exec_block.rs): If, For/For Each, Do (pre/post While/Until), While, Select Case (values/ranges/Is), With - Calls (call.rs): positional/named/Optional/ParamArray binding + ByRef copy-in/copy-out for lvalue args; End/Stop halt sentinel - Built-ins (builtins/): ~40 pure functions (math, conversion, strings, array/ info) with no host access; is_builtin gate; date-serial helper - 20 end-to-end conformance tests (recursion, ByRef, On Error, fuel exhaustion, overflow, Select, For Each, ...) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Rounds out Phase 2's verification (spec §12): - Purity gate (scripts/check-loki-basic-pure.py, wired into CI): asserts loki-basic depends only on its allow-list (no I/O — every effect via the Host trait) and that no server/headless crate links the interpreter (spec §10) - cargo-fuzz harness (loki-basic/fuzz/, detached from the workspace): lex/parse/ interp targets for parser-hardening (T9) - In-tree panic-freedom smoke tests over adversarial input (runs in normal CI) - Split exec.rs → exec_dim.rs to hold the 300-line ceiling - Baselined the deliberate suppressions (optional-token , documented numeric-cast/dead-code allows); dropped the resolved loki-odf entry - fidelity-status.md §12 updated: interpreter core = Partial (Phase 2 landed), in-app execution surface still deferred to loki-macro-host Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
…-code) New L2 crate loki-vba reads module source text from vbaProject.bin for the macro viewer, under the hard rule that compiled p-code is never parsed or run (macro spec §4.4, threat T5 — VBA stomping): - MS-OVBA decompression (§2.4.1) in pure Rust: LZ77/RLE with per-chunk 4096 cap + 64 MiB global bomb guard; literal/copy/overlap paths tested - CFB walk (cfb crate) locates the /VBA storage by its dir stream; dir-stream parser (§2.3.4.2) reads code page + per-module name/stream/offset/type, skipping unknown records by size - Per-module: decompress from MODULEOFFSET only (the compiled cache before it is ignored), decode via the project code page (encoding_rs), normalise CRLF - Tamper heuristic: empty source + substantial p-code → 'possible VBA stomping' warning; stomped modules read as empty (inert here — Loki runs no p-code) - Every failure is a typed VbaError, never a panic (§12, T9); malformed input degrades to unreadable - End-to-end tests synthesize a real compressed vbaProject.bin and read it back - Registered in workspace members + dependency-direction gate (L2 leaf) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Visibility before executability (spec §9.6): users can now inspect what a document's macros would do, read-only, before any trust decision. - loki-odf::basic — extract StarBasic modules (name + source) from the preserved Basic/ XML parts; skips library indexes and dialogs - loki-text macro viewer: the macros infobar gains a 'View macros…' action opening a read-only, monospace source panel (module tabs + selected source), surfacing the VBA-stomping tamper warning. Source is extracted on demand (VBA via loki-vba source-only; StarBasic via loki-odf), so the infobar stays a cheap presence check. Mount stays within editor_inner's 1-line budget (present:bool → ctx:MacroCtx, Arc-identity PartialEq) - macros.ftl: viewer strings (title/close/empty/unreadable/tamper/view-action) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
…status - cargo-fuzz harness for loki-vba (decompress/read targets; detached workspace) - in-tree panic-freedom smoke tests over adversarial/truncated OVBA + CFB input (bomb-guard bound, no hang/panic) - unsafe-policy gate skips cargo-fuzz harnesses (bin-only, no src root) — also clears the latent loki-basic-fuzz discovery - fidelity-status.md §12: VBA/StarBasic source extraction + read-only viewer marked landed; in-app execution surface still deferred to loki-macro-host Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds the security spine for macro support (macro spec §2, §5, §7 — no
execution yet; that is Phase 5). New L5 crate loki-macro-host:
- Capability: closed catalog (§5.2) — DocRead baseline, DocWrite/UiDialog/
Clipboard{Read,Write}/File{Read,Write}/Print prompt, Network refused in
v1. GrantScope Deny/AllowOnce/AllowSession/AlwaysForDocument (no
all-documents scope). CapabilityDecision Granted/Prompt/Denied/Refused.
- TrustStore: per-user, local, JSON-persisted, keyed by the macro-payload
hash (§2.4). Nothing in a document can influence its own trust (T10):
a fresh store trusts nothing, and editing the macros changes the key so
trust drops. Session-only trust never reaches disk; a corrupt store
degrades to empty so it can't block opening.
- CapabilityBroker: the loki-basic Host impl (§4.3, §5.1) — pure evaluate()
decision surface plus fuel metering and a shared cancel flag (§8). UDFs
run compute-only (§6.3): every effect denied, never prompts.
- MacroService: cheap-clone Arc handle (SpellService pattern) wrapping the
store + per-document session state; trust decisions, capability grants,
revocation, forget, and Document Security summaries for the panel.
Registered in the workspace, the dependency-direction gate (L5), and the
interpreter-isolation gate. Baselines the two Phase-3 loki-vba fuzz targets
(pre-existing panic-freedom `let _ =` harnesses) in the suppression ratchet.
40 unit tests: capability matrix, grant-scope honouring, immediate
revocation, T10 trust-forgery, and persistence round-trips all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds the macro-originated dialog surface (macro spec §5.5, §2.3, §5.4), rendered in a visually distinct frame that app chrome never uses so a malicious macro cannot impersonate a genuine app dialog (threat T7): - New reserved accent token COLOR_MACRO_BADGE (violet), documented as never-for-chrome. - MacroDialogFrame: badged "Macro: <project>" header + document title over a violet-bordered card on a dimming backdrop (same positioned-ancestor contract as AtConfirmDialog). - AtMacroTrustDialog: the three §2.3 choices (Keep disabled / Enable for session / Trust). AtPermissionPrompt: a first-use capability prompt with Deny as the default and Allow once/session/always. Both stay appthere_ui-pure — display strings in, an abstract choice enum (MacroTrustChoice / MacroGrantChoice) out — so the crate takes no macro-host or document dependency; the app maps the choice. macros.ftl gains the badge word, permission-prompt buttons, per-capability names/consequences (keyed by Capability::id()), and Document Security panel strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Completes Phase 4 by connecting the trust store and capability broker to the app (macro spec §9). Still no execution surface — this records the decision only; Phase 5 wires running a macro. - app.rs provides MacroService into context (SpellService pattern), loaded from the suite data dir via a new loki_app_shell::app_data module that centralises the per-user data-dir convention (spell cache + macro trust store now share one root; data_root's cfg split drops a stale #[allow(unreachable_code)]). - editor_macro_notice is now trust-state aware: the infobar offers "Enable options…" (opens AtMacroTrustDialog) + "View macros…" when disabled, and switches to a session/trusted note with "Document security…" once enabled; choices are recorded through MacroService. - editor_macro_security_panel: the Document Security panel (§9.4) — trust state, granted capabilities with immediate Revoke, the auto-run-on-open opt-in (§5.6), and "forget this document". - AtInfobar gains an optional secondary action so both Enable and View fit. fidelity-status §12 updated: Phases 1–4 implemented; execution (Phase 5) not started. Workspace clippy -D warnings, fmt, and all CI gates green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Extends the interpreter from a pure calculator to a capability-gated execution engine (macro spec §4.3, §6, §7) — still routed entirely through the Host seam, so it has no ambient authority of its own. - Host trait gains the effect surface: get_root / get_member / set_member (object model) and dialog (MsgBox/InputBox), each defaulting to deny so NullHost/FuelBudget stay pure calculators. - Value::Object(ObjectRef) flows through variables, Set, and With; the Is operator compares object identity (and `x Is Nothing`). Coercions treat an object as a type mismatch; IsObject/TypeName handle it. - Interpreter dispatch: Member get/method-call/property-set to the host, object-model root resolution (Application/ActiveDocument/…), WithContext, and MsgBox/InputBox routed to Host::dialog. The built-in Err object (Number/Description/Clear/Raise) is wired. - "never" list (§7): Shell, CreateObject/GetObject, createUnoService, Kill/ Dir/MkDir/…, GetSetting/SaveSetting/Environ, DDE*, OnTime/Timer, and Declare'd FFI names raise an UNTRAPPABLE feature_refused (1004) — bare or as a member — so On Error Resume Next can't slip past them. Tests: object-model dispatch + dialogs (host_tests, 12), one assertion per §7 row incl. untrappability (refusal_tests, 12), and the now-wired Err object. loki-basic stays zero-I/O (purity gate green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
The capability broker now drives real execution (macro spec §5, §6). - ExecutionHost implements loki-basic's Host by composing the CapabilityBroker (fuel + cancel + decisions), a MacroBackend seam (the app's prompt + dialog surface), and a document facade. Every effect flows through gate(): Granted proceeds, Prompt asks the backend, Denied → a trappable "permission denied" (70), Refused → an untrappable feature-refusal. - Object-model facade v1 (§6.1): Application.Name/Version, ActiveDocument → Document with Name/Text/Content/ParagraphCount (DocRead), AppendText/ InsertText/TypeText + Text= (DocWrite), PrintOut (Print). Reads are baseline; writes accumulate into one EditBatch. - EditBatch (§6.2): every DocWrite is recorded; the app applies the whole batch as one Loro transaction = one undo entry. - MacroRuntime::run executes a NAMED procedure only — no auto-open/event discovery (§5.6), closing the T1 vector by construction. Returns the batch and a typed MacroRunError (is_refusal / is_resource_stop). list_procedures feeds the Tools ▸ Macros picker. DenyBackend = compute-only (UDF posture). Exit-criteria tests (runtime_tests, 12): "never" table inert through the host, auto-open never fires, DocWrite denied is trappable + makes no edits, a granted multi-write run is one batch, and an infinite loop is fuel-stopped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Records Phase 5 (engine) status in §12: interpreter core now Yes (effect surface wired), the "never" list and execution engine + facade rows added, and the in-app Tools ▸ Macros runner marked as the remaining tail — worker-thread + Stop + Loro-grouped-undo application need GUI verification and are landed in a follow-up rather than shipped blind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Wires the execution engine into loki-text so an enabled document can actually run its macros (macro spec §6, §9.3). - editor_macro_apply: applies a run's EditBatch to the live Loro document with the same section-0 text/block primitives the editor uses, then commits ONCE — and one commit is one undo checkpoint, so a whole macro run is exactly one ⌘Z (spec §6.2). Unit-tested against a LoroDoc. - editor_macro_run: reads the document body, resolves capabilities from the trust record (MacroService.grant_set_for), runs the named proc via MacroRuntime, and applies edits on success. RunnerBackend collects MsgBox/InputBox text into an output log. Refusal/denial/resource-stop map to plain-language guidance. End-to-end tested (granted write reaches Loro; refused/denied make no edits). - editor_macro_runner: the runner panel — lists runnable procedures across modules and runs a chosen one; reachable via "Run a macro…" in the Document Security panel. macros-run-* i18n strings added. v1 posture (deliberate — ships without an un-verifiable async UI): grants are pre-resolved (no mid-run prompts; grant in Document Security and re-run), dialogs are logged rather than shown as blocking modals, and the object model is plain-text. Interactive prompts + live modals + a worker-thread Stop, and rich-text/spreadsheet writes, are the documented follow-on (fidelity-status §12). Workspace clippy -D warnings, fmt, and all gates green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Replaces the pre-resolved synchronous runner with the full interactive layer (macro spec §5.4, §5.5, §8): the macro runs on a worker thread while the UI stays responsive. - editor_macro_bridge: the worker↔UI bridge. The BridgeBackend (MacroBackend) posts each capability prompt / dialog over a `futures` unbounded channel and blocks the worker on a per-request std-mpsc reply. Sequential by construction (single-threaded interpreter). Cancel-aware: Stop trips the shared flag and, if the worker is blocked on a prompt, the UI answers it deny/cancel so the next fuel step aborts; a gone UI degrades to deny. Proven with threaded tests (grant/deny/dialog/Stop-loop/Stop-during-prompt). - editor_macro_prompt: renders the live prompt in the anti-spoof frame — AtPermissionPrompt for capabilities, MacroDialogFrame for MsgBox/InputBox (with a text field). Grants the user allows persist to the trust record. - editor_macro_runner(+_ops): dioxus::spawn a worker running MacroRuntime with the bridge backend; drain prompts via StreamExt::next, render them, apply the batch as one undo entry on finish, and expose an always-available Stop. - editor_macro_run refactored into UI-thread helpers (make_run_request + apply_and_report) shared by the run path and tests. fidelity-status §12: the in-app runner is now fully interactive; remaining tail is rich-text/multi-section writes + the spreadsheet object model. Workspace clippy -D warnings, fmt, and all gates green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Engine for the two security-critical Phase 6 surfaces (macro spec §5.6, §6.3), fully unit-tested. - events: recognises the well-known auto-run handler names (Document_Open, AutoOpen, Workbook_Open, OnLoad, …) and their phase (open/close/save). auto_open_handlers() filters a proc list to the on-open (T1) set. - Auto-run is type-gated: AutoRunToken has no public constructor; MacroService::authorize_auto_run yields Some ONLY when the document is persistently trusted AND the user set auto_run_open. MacroRuntime::run_event requires the token, so an app cannot fire an on-open handler without passing that gate — "nothing fires without the flag" enforced by the type system. - MacroRuntime::eval_udf: compute-only spreadsheet UDFs (§6.3). Runs a function with CapabilityBroker::for_udf (zero capabilities — not even DocRead — and no prompts) under a tight fuel budget; any object-model access, dialog, "never"-list call, error, or runaway loop returns UdfOutcome::Macro (the cell's #MACRO!). T1 regression corpus (events_udf_tests): disabled / session-only / trusted- without-flag all refuse to authorize; only trusted+flag mints a token; revoking the flag or keep-disabled revokes it. UDF corpus: pure compute returns the value; DocRead / MsgBox / Shell / infinite-loop / unparseable all → #MACRO!. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
On-open handlers (Document_Open/AutoOpen/…) now fire in the word processor,
but only when the document is already trusted AND the user set the per-document
auto_run_open opt-in — the T1 exit criterion ("nothing fires without the flag").
- editor_macro_runner_ops: launch() gains an `auto` path re-checking
MacroService::authorize_auto_run at fire time and driving the token-gated
MacroRuntime::run_event; start_auto_run wrapper + auto_open_entries filter.
- editor_macro_runner: `auto_fire` prop fires the first on-open handler once
on mount via use_hook.
- editor_macro_notice: a per-payload-hash use_effect (re-runs on doc load)
authorizes then mounts the runner with auto_fire; extraction split into
editor_macro_extract for the 300-line ceiling.
- AutoRunToken is now Copy so it can cross into the worker thread.
- fidelity-status §12: record Phase 6 (auto-run gating + UDF core); tail =
button/control-assigned macros, class modules, Find, live UDF recalc wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds `Class <Name> … End Class` class modules (macro spec §4.2): instance
construction via `New`, fields, `Sub`/`Function`/`Property Get/Let/Set` methods,
`Me`, and implicit member access inside method bodies. Reference semantics fall
out of reusing `Value::Object` handles (Set/Is/With unchanged).
Security: a class instance is pure interpreter heap. Handles are allocated from
a high partition (USER_OBJ_BASE) and dispatched from the interpreter's own
`instances` table BEFORE the Host fallthrough, so a user class never reaches the
capability seam — it grants a script no authority it did not already have. This
is verified by running the class tests against NullHost. `New` of an unknown
(non-user) class stays an untrappable feature-refusal (external COM/ProgID, §7).
- ast: Item::Class(ClassDef { name, fields, methods }).
- parser: Class body reuses parse_item for members; also accept bare
`Public/Private/Static name As Type` fields (no `Dim`) at module/class level.
- interp/class.rs: Instance table + construct/get/call/set/implicit dispatch and
Me-bound method invocation; eval/call/exec route instances before the host.
- 10 class tests (New, fields, property pairs, Me, by-ref sharing, identity,
args, refusal, 438) all green against NullHost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds the Word Find/Replacement surface (macro spec §6.1) over the neutral document body: Selection.Find / Range.Find with .Text, .Replacement.Text, .MatchCase, .WholeWord, and .Execute (search / replace-all). Semantics: Execute replaces every match with Replacement.Text when a replacement has been set (Some, including "" to delete); otherwise it searches only. The Replace:= argument is deliberately not parsed — once the host seam drops argument names its position is unreliable, so Replacement.Text is the single predictable replace signal (documented). MatchCase (ASCII case-fold when off) and WholeWord are honoured via a char-based, non-overlapping matcher. Gating: a search-only Execute gates DocRead (baseline); a replacing Execute additionally gates DocWrite and records exactly one DocEdit::SetText, so a find/replace run is one undo entry. Denied DocWrite is trappable (70) and records no edit. - exec/mod.rs: SELECTION/FIND/REPLACEMENT handles, selection/range roots, FindState on DocFacade. - exec/find.rs: get-side dispatch + matcher; facade.rs: set-side params + Document.Range. - 9 tests (replace-all as one batch, search-only, case sensitivity, whole-word, empty-replacement delete, denied-write, Range alias). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Cell formulas can now call the workbook's macro functions. An unknown function
name routes to MacroRuntime::eval_udf (compute-only, macro spec §6.3): the UDF
gets zero capabilities (not even DocRead), cannot prompt, and is fuel-bounded, so
recalculation stays pure, fast, and unpromptable. Any object-model access,
dialog, "never"-list call, error, or runaway loop yields #MACRO!.
Security note: a UDF is safe by construction, so it needs no trust grant — unlike
an executable macro (disabled by default), a UDF cannot read the document, reach
the network, or touch the filesystem, so evaluating one from any workbook cannot
cause harm.
- formula/udf.rs: UdfResolver indexes procedures by name from the preserved VBA/
Basic payload and evaluates one via eval_udf.
- formula/mod.rs: CellValue{Num,Text} + FormulaError::Macro (#MACRO!); threads the
resolver through evaluate_cell/evaluate_formula.
- formula/eval.rs + funcs.rs: unknown names dispatch to the UDF (numeric context);
a whole-formula UDF may return text. eval.rs split under the 300-line ceiling.
- editor_load.rs: XlsxImport/OdsImport::run retains the payload; udf_from builds
the resolver. editor_state/editor_inner hold it in a signal and pass it into
the render-time evaluator.
- 6 integration tests (numeric, text, #MACRO! on doc-access / runaway, #NAME? for
unknown, builtins not shadowed).
Limitations (documented): UDF arguments are numeric/cell-reference only (the
formula lexer has no string literals); a UDF text result is usable only as a
whole-formula value, not inside arithmetic (→ #VALUE!); eval_udf re-parses the
module per call (bounded to cells that actually use a UDF).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Word MACROBUTTON fields carried the control→macro assignment but were dropped to
inert FieldKind::Raw text on import. This models them as a typed
FieldKind::MacroButton { macro_name, display }, so the assignment is preserved
(and round-trips: the DOCX exporter re-emits `MACROBUTTON <macro> <display>`),
and the button renders its visible label in layout.
Security: modelling the assignment never implies running it — execution stays
gated by the same trust rules as any macro (disabled by default for documents the
user did not author, spec §2). The macro name is now discoverable/runnable through
the existing trust-gated Tools ▸ Macros runner.
- loki-doc-model: FieldKind::MacroButton variant.
- loki-ooxml: parse MACROBUTTON at import + re-emit on export; round-trip tests.
- loki-layout: MACROBUTTON renders its label.
Deferred (documented in fidelity-status §12): in-page click-to-run — a control
region hit-test dispatching a trust-gated run — and ActiveX/OLE form controls.
Both need the greenfield control-modeling chain (body-model control geometry +
layout hit-testing + export), out of scope for this pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
…on in fidelity-status §12 now reflects the four tail items as landed (class modules, Find/Replacement, compute-only spreadsheet UDFs wired into recalc, MACROBUTTON modelling) with the honest remainders documented: in-page click-to-run + ActiveX/OLE controls, UDF string-arg/text-in-arithmetic limits, and multi-module .cls class registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Lays the groundwork for in-page click-to-run. A MACROBUTTON's layout run now carries a `loki-macro:<name>` link URL, so the editor's existing hyperlink hit-test (link_at) surfaces the click — the loki-text side then routes the `loki-macro:` scheme to the trust-gated runner instead of a browser. - loki-doc-model: MACRO_LINK_SCHEME const + FieldKind::macro_link() — single source of truth for the pseudo-URL both layout and the editor agree on. - loki-layout: the Inline::Field walk tags a MacroButton run's link_url with the macro scheme (falling back to any enclosing hyperlink for non-macro fields); test asserts the label renders and the span carries loki-macro:RunReport. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Add verify_signed_data to loki-macro-sig: parses a detached PKCS#7 SignedData over the macro payload and returns a total SignatureVerdict. - verify.rs: ContentInfo -> SignedData -> first SignerInfo; checks the signedAttrs message-digest == digest(content) (ContentMismatch else), then verifies the signature over the 0x31-re-encoded signedAttrs (RFC 5652 §5.4 — the `cms` SetOfVec `to_der()` gives the universal SET OF tag, not the [0] IMPLICIT one). No signedAttrs -> verify over content directly. Verdict precedence: LegacyAlgorithm > CertificateExpired > NotPinned. Never returns ValidTrusted — trust is a caller decision against a user-pinned publisher store (8A.5, T10). - verify_crypto.rs: DigestId/SigKind OID agility (SHA-2/1 + MD5; RSA PKCS#1v1.5 + ECDSA P-256), digesting, and the crypto check. Legacy digests are checked but flagged, never trust-eligible. - verify_cert.rs: signer-cert resolution (issuer+serial / SKI), CertInfo extraction (CN, DNs, serial hex, validity), SHA-256 leaf thumbprint over the re-encoded cert DER. - Tests build real detached CMS in-process over fresh self-signed RSA and P-256 certs (rcgen + RustCrypto signers): happy paths, tampered content, corrupt signature, expired cert, legacy SHA-1, and garbage/panic-freedom. Real-Office-corpus cross-validation stays a TODO(8A.3-corpus) gate. Also drop the discarded binding in the 8A.2 fuzz target (black_box), which had left check-suppressions red on the branch. Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Two allowlist additions from the 8A.3 work (reading rcgen's source while building the self-signed signature fixtures). Local settings only; no code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Add verify_xmldsig to loki-macro-sig: verifies the W3C XMLDSig macro signatures ODF stores in META-INF/macrosignatures.xml, returning the same total SignatureVerdict as the CMS path. - xml_c14n.rs: inclusive Canonical XML 1.0 (without comments) over a small namespace-resolved node tree — the transform XMLDSig signs SignedInfo under. Handles namespace hoisting onto the node-set apex, attribute sorting by (namespace URI, local name), and the c14n text/attr escaping. Unit-tested against hand-derived vectors. - odf_dom.rs: a total quick-xml -> tree builder (whitespace preserved, CRLF normalised, predefined entities resolved; comments/PIs/decl/DOCTYPE dropped as c14n omits them). - odf.rs: structural macrosignatures.xml parse — SignedInfo (with in-scope namespaces flattened onto the apex), references, signature value, and the X509 certificate — resolving no algorithms, so an unknown one still parses and reads as UnsupportedAlgorithm rather than vanishing. - verify_odf.rs: for each Signature, check every package-part Reference digest against the real part bytes (caller resolve_part closure), canonicalise SignedInfo, and verify its SignatureValue — reusing the shared cert (verify_cert) and verdict-reason (verify.rs) layers. - verify_crypto.rs: add EcdsaEncoding so ECDSA verifies raw P1363 r||s (XMLDSig) as well as DER (CMS); the CMS path passes Der unchanged. - Tests build real macrosignatures.xml with a canonical hand-authored SignedInfo signed by fresh self-signed RSA / P-256 keys: happy paths, tampered/missing part, corrupt signature, expired cert, legacy sha1, unknown algorithm, unsigned, and panic-freedom. New cargo-fuzz target. Real LibreOffice/Word corpus interop stays a TODO(8A.4-corpus) gate; in-document #-fragment reference digests are authenticated by the SignedInfo signature but not independently re-canonicalised. Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Add the *trust* half of the signed-macro tier to loki-macro-host (now
depending on the L2 loki-macro-sig verifier). Verification proves
authorship; trust is a separate, explicit user act (ADR-0014 §4.3/§4.5,
T10) — pinning a signer certificate's SHA-256 thumbprint.
- trust/publisher.rs: TrustedPublisherStore, a per-user profile-JSON store
keyed by thumbprint (sibling of TrustStore, same "nothing in a document
writes it" rule; same load/load_or_empty/save robustness). PublisherRecord
carries the thumbprint plus display name and subject/issuer (identity kept
only for renewal detection). pin/unpin (un-pin is the local revocation
mechanism), contains, renewed_match.
- resolve(): the ONLY place a verified signature becomes ValidTrusted —
upgrades a NotPinned verdict iff the signer thumbprint is pinned; flags a
pinned-identity/new-thumbprint certificate as PublisherRenewed (re-pin
affordance); and, as the downgrade/expiry defence, never upgrades a
LegacyAlgorithm or CertificateExpired verdict even when pinned. All other
verdicts pass through unchanged.
- trust/record.rs: add Provenance::TrustedPublisher { thumbprint } (hex
serde), the provenance a document open records when a signature resolves
to ValidTrusted.
- Unit tests cover upgrade, renewal, non-renewal (different identity), the
never-upgrade guards, pass-through of Invalid/Unsigned, pin/contains/unpin,
from_cert_info, and persistence round-trip / corrupt-file degradation.
Update fidelity-status §12. Trust-dialog/pin UI is 8A.7; open-path wiring
and RFC-3161 timestamps are 8A.8/8A.6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
The CMS verifier now honours an expired signing certificate when the signature carries a bound RFC-3161 timestamp proving it was signed while the certificate was valid (ADR-0014 §4.4). - timestamp.rs: reads the id-aa-timeStampToken unsigned SignerInfo attribute, decodes the TimeStampToken (ContentInfo/SignedData) to its TSTInfo, and extracts genTime — but only after verifying the token's messageImprint hashes to exactly this signature's octets, so a timestamp cannot be transplanted from another signature. - verify.rs: is_expired_at() treats a genTime within [notBefore, notAfter] as valid-at-signing, so an expired-but-timestamped signature reads NotPinned (eligible for the 8A.5 pin upgrade) instead of CertificateExpired. untrusted_reason gains the signed_time argument; the ODF path passes None. - Tests build real expired-cert CMS fixtures with a bound timestamp: in-window rescues, out-of-window and no-timestamp stay expired, and an unbound (wrong-imprint) timestamp is ignored. Deliberate limitation TODO(8A.6-tsa-anchor): the timestamp is bound and parsed but the TSA's own signature/chain is not anchored to a trusted timestamping root. This is safe under the leaf-thumbprint trust model — only a pinned publisher's genuinely-signed content is ever rescued, so a forged genTime cannot make attacker content trusted — and should be revisited only if a chain-to-CA trust mode is added. ODF/XAdES timestamps are a separate TODO(8A.6-xades). Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Surface macro-signature state and the "Trust this publisher" flow. loki-macro-host: - MacroService now owns a TrustedPublisherStore alongside the TrustStore (a sibling profile file), records each open document's verdict via set_signature, and resolves it against the pins on read so a fresh pin flips the shown trust live. New pin_publisher / unpin_publisher / trusted_publishers / is_publisher_trusted. - SignatureSummary / SignatureStatus view model folds a SignatureVerdict into the states the UI distinguishes (Unsigned / Invalid / Untrusted / Legacy / Expired / Renewed / Trusted); can_pin is true only for Untrusted and Renewed. Unit-tested: pinning flips a NotPinned summary to Trusted; expired/legacy never upgrade even when pinned. loki-i18n: macros-sig-* strings (signature states, Trust-this-publisher confirm, management list, revocation note). loki-text: MacroSignatureSection renders inside the Document Security panel — 🔏 signed-by + issuer + thumbprint, a "Trust this publisher…" action behind an anti-spoof-framed inline confirm, the trusted note, and the pinned-publisher management list with per-row Remove. Open-path wiring (verify on open → set_signature → enabled-at-open) and the editor "editing removes the signature" warning are 8A.8. Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
…servation Wire the verifier to a real opened document, end to end for ODF. loki-odf: preserve META-INF/macrosignatures.xml / documentsignatures.xml alongside the Basic/Scripts parts (they are not manifest-declared, so they are collected out-of-band and, on export, written to the ZIP but kept out of the manifest). This gives the ODF XMLDSig verifier real input and fixes a round-trip gap (signatures were silently dropped on save). loki-macro-host: - verify::verify_payload(&MacroPayload) -> SignatureVerdict: locates the signature in a preserved payload and feeds the verifier the exact source bytes. ODF is verified end-to-end (macrosignatures.xml XMLDSig checked against the other preserved parts via a resolver closure); VBA is deferred (TODO(8A.8-vba-content): needs the MS-OVBA content hash) and reads Unsigned rather than a misleading Invalid. - MacroService::verify_and_record — the open-time entry point — and is_enabled now also returns true for a pinned trusted publisher (enabled at open, ADR-0014 §4.5; sensitive capabilities still prompt and auto-run still needs its own opt-in). - End-to-end tests build a real signed ODF macrosignatures.xml: verify → ValidUntrusted, tamper → Invalid, unsigned → Unsigned, VBA → Unsigned, and pin → is_enabled at open. loki-text open-path wiring and the editor "editing removes the signature" warning are part 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Wire signature verification into the loki-text document-open flow,
completing Track A.
- use_verify_signature_effect: on document load, verify the preserved macro
signature once per payload and record the verdict in the MacroService
(before the auto-run gate), so a trusted-publisher document is recognised
at open (ADR-0014 §4.5).
- The macros infobar reflects publisher trust: a signed-by-trusted-publisher
document reads as enabled at open ("Macros enabled — signed by a trusted
publisher") without a per-document decision. Sensitive capabilities still
prompt; auto-run still needs its own opt-in.
- The macro editor shows an "editing removes the publisher's signature"
warning atop a signed project (ADR-0014 §4.6).
- macros-sig-enabled-publisher / macros-sig-edit-warning strings.
Update fidelity-status §12: Track A complete; residual gates
(VBA content hash, corpus validation, TSA anchoring, XAdES timestamps)
documented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Four fixes from the Track A self-review: 1. Signature coverage (security). verify_xmldsig now takes a require_covered list and rejects (ContentMismatch) any signature that does not reference every part the runner will execute. verify_payload passes all non-empty Basic/Scripts file parts. Previously a signature that referenced zero (or only a subset of) the macro modules verified as valid, so unreferenced — hence unsigned — modules ran under a "valid"/trusted signature. 2. MACROBUTTON click gate. use_click_dispatch_effect now gates on svc.is_enabled() rather than decision_for().is_enabled(), so a trusted-publisher document (enabled at open, no per-document record) runs a clicked macro instead of re-prompting — matching the infobar. 3. Editing drops the signature. build_edited_payload removes the META-INF/*signatures.xml parts, so an edited document is saved cleanly *unsigned* instead of carrying a stale signature that other apps flag as tampered — making the editor warning truthful (ADR-0014 §4.6). 4. Remove a dead empty `else if` branch in the signature section. New tests: signature-not-covering-a-required-part → Invalid (verifier), unreferenced-module → Invalid (host open path), and editing-drops-the- signature (editor ops). Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Start Track B (ADR-0015). The Network capability becomes reachable only through a bounded, origin-scoped, session-max path — never a wholesale "network on" grant. - net.rs: NetworkPolicy (per-run enabled flag + allowed origins) and MACRO_NET_COMPILED (the cfg!(feature = "macro-net") constant). Off by default; the app enables only when the build feature AND the runtime setting are both on (ADR-0015 §8 decision 1). Grants never persist to disk (§4.2, the T4 exfiltration edge). - broker.rs: CapabilityBroker gains a network policy (with_network) and evaluate_network / apply_network_prompt. Network is Refused when disabled, Denied in a UDF, Granted only for an already-allowed origin, else Prompt — per distinct origin, no wildcards. The generic evaluate(Capability::Network) stays Refused (a bare network switch is never a grant). - Cargo.toml: off-by-default `macro-net` feature (excluded from the iOS build per §8 decision 5). - Tests (net + broker), passing with the feature both off and on. Deferred to 8B.2–8B.6: the HttpGet shim, the reqwest HTTPS-only backend, bounds/Stop, the per-host prompt + composition warning, and the always-refused headless/server backend. Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
The object-model network verb (ADR-0015 §4.1). Read-only GET first; HttpPost stays deferred (§8 decision 3). - http.rs: HttpRequest / HttpResponse / HttpError plus origin_of (the grant unit) — https-only, userinfo rejected (spoofing / ambient-cred smuggling). HttpResponse exposes body_as_string (lossy; never parsed into a privileged format, T9) and case-insensitive header lookup. - exec: MacroBackend gains prompt_network + http_get, both defaulting to refused so any backend that does not opt in (UDF, headless, tests) has no network. ExecutionHost.http_get validates the URL, gates the origin through the broker's per-origin path (gate_network, prompting via the seam), performs the fetch, and returns an HttpResponse object handle; the macro reads .Status / .Text / .Header. Split into exec/network.rs for the 300-line ceiling. - RunRequest.with_network threads the origin-scoped policy into the run. - End-to-end tests: fetch reads back 200/"pong"; a disabled policy refuses untrappably before the backend; a denied origin never reaches the backend; a non-https URL is rejected before prompting. Passing with the macro-net feature both off and on. Update fidelity-status §12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Post-rebase reconciliation after replaying the macro-scripting branch onto the new main tip (DOCX repair + layout/ooxml/odf work): - scripts/suppressions-baseline.txt: regenerated with check-suppressions.py --update so the ratchet reflects the merged tree (main's + macro suppressions combined); gate passes. - loki-layout/src/resolve_inlines.rs: main brought this file to exactly the 300-line ceiling; the MACROBUTTON field-display arm pushed it to 303. Collapsed the redundant `String::new()` match arms (all already covered by the existing `_` wildcard) into that wildcard — behaviour unchanged, file back to 295. - loki-text/src/routes/editor/editor_inner.rs: dropped one blank line so the macro wiring (macro_run_request signal + MacroNoticeBar mount) keeps the file at its 800-line baseline instead of growing to 801. All CI gates green; cargo check --workspace, clippy on the touched + macro crates, and the affected layout/hit-test/macro-host suites pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Implements the real macro `Network` transport behind the off-by-default `macro-net` build feature (ADR-0015 §4.1, §4.3). - net_fetch.rs (compiled only under `macro-net`): `NetFetcher`, a blocking reqwest/rustls client that performs a bounded, read-only HTTPS GET. The client follows **no** redirects itself (`redirect(Policy::none())`) so each hop is re-gated in-process against the session origin allow-list; it sets `https_only`, carries no cookie store and no default/proxy auth (no ambient credentials leave — T4), and a 30 s timeout. - net_policy.rs (always compiled, reqwest-free so it unit-tests without the feature): the header deny-list (framing/hop-by-hop + ambient-credential `Cookie`/proxy-auth stripped; an author-set `Authorization` is kept — it is the author's own explicit credential), relative-redirect resolution + per-hop origin re-check (`redirect_next`), and the hop/size bounds. A redirect to a not-yet-granted origin is refused (trappable), never silently followed. 12 unit tests. - MacroBackend::http_get now receives the granted origins so a backend can bound its redirect following; ExecutionHost::http_get snapshots them from the broker after gating (new CapabilityBroker::network_origins). Both feature configs pass clippy -D warnings and tests. A true streaming size-cap and Stop-cancels-the-request are the 8B.4 follow-ups (TODO(8B.4)); wiring the app BridgeBackend into NetFetcher lands with the per-host prompt in 8B.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Turns the read-then-measure size check into a true bound and wires the shared Stop cancel flag into the fetch (ADR-0015 §4.1). - net_policy::read_body_capped (pure, reqwest-free, unit-tested): streams a response body reading at most `cap + 1` bytes, so an undeclared or endless over-cap body is bounded without a large allocation, and checks the shared cancel flag between 16 KiB chunks so Stop lands promptly. - NetFetcher::fetch now takes the cancel flag, checks it before each redirect hop, and reads the terminal body through read_body_capped (keeping the cheap content-length early-reject). New HttpError::Cancelled maps to a trappable interrupt. - The 30 s whole-request timeout still bounds how long Stop can take to land while blocked in connect/TLS; per-run-configurable timeout is TODO(8B.4-config). 4 added unit tests (under/at/over cap, cancel-before-read). Both feature configs pass clippy -D warnings and tests; workspace builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds the app surface for the macro Network capability (ADR-0015 §4.2, §4.6). - appthere_ui::AtNetworkPrompt: a per-host prompt in the anti-spoof MacroDialogFrame. Shows the destination origin verbatim (in its own field so a look-alike host is legible) and offers only Deny / Allow-once / Allow-session — no "always for this document", since network grants are session-max. Always carries the composition warning (§4.6), which applies unconditionally because DocRead is baseline (a macro can always read the document). TODO(8B.5-homograph) for punycode/homograph display. - loki-text BridgeBackend: implements prompt_network (forwards a new UiRequest::Network(origin) through the existing worker↔UI bridge, blocking for the answer) and, under the macro-net feature, http_get (lazily builds a NetFetcher and fetches on the worker thread with the shared Stop cancel flag). New macros-net-* i18n strings. loki-text gains a macro-net feature that enables loki-macro-host/macro-net. - answer_prompt skips capability-grant recording for a Network request (the broker records the session origin during the run); TODO(8B.5-session-origins) for cross-run session-origin memory. Threaded test: HttpGet to a new origin raises the per-origin prompt with the exact origin and a Deny traps the call (holds under either macro-net config). Both feature configs pass clippy -D warnings and tests; gates green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Completes Track B by documenting and testing the always-refused posture for headless/server/UDF contexts (ADR-0015 §8 decision 4/5). Two guarantees keep macro network access off everywhere except an opted-in interactive client: 1. Compile-time: no server or headless crate links loki-macro-host at all (it pulls the loki-basic interpreter, which check-loki-basic-pure.py forbids server/headless crates from linking), so the reqwest/rustls transport is not even present in those binaries. 2. Run-time: every non-interactive run uses DenyBackend (whose http_get refuses), and a spreadsheet UDF is additionally compute-only (network disabled, cannot prompt) — HttpGet there yields #MACRO!. tests/network_refusal_tests.rs covers the run-time guarantees in both feature configs; the compile-time one is enforced by the purity gate in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds the read half of picker-mediated file access to the macro object model (macro spec §5.3, threat T3). There is no path-addressed file API — a macro can only ever touch a file the user hands it through the OS picker. - file.rs: PickedFile (path + bytes, run-scoped, read-only) and FileFilter with a tolerant parser (*.txt, *.a;*.b, "Text Files (*.txt),*.txt", txt,csv → bare lower-cased extensions). Unit-tested. - MacroBackend::read_file(filter) -> Option<PickedFile> seam (default None, so UDF/headless/tests read no files — the pick is the app's job). - exec/file.rs: Application.OpenFileForReading([filter]) gates the FileRead capability, raises the picker via the backend, and returns a run-scoped handle. A cancelled/empty pick is a trappable error (75). Handle members .Path / .Text (.ReadAll) / .Length read the picked bytes with no further capability (the gate + pick were the gated acts). FILE_HANDLE_BASE is a distinct handle range above HTTP_RESPONSE_BASE (checked highest-first). End-to-end tested with a mock picker (tests/file_tests.rs): reads into the doc, denied FileRead never raises the picker, a cancelled pick is trappable (directly and via On Error), and .Path/.Length read back. Both feature configs pass clippy -D warnings and tests; gates green; workspace builds. Still deferred: the loki-text app-side picker bridge (async OS picker over the worker↔UI bridge) and FileWrite (picker-chosen save target). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Adds the write half of picker-mediated file access (macro spec §5.3, threat T3). Symmetric with FileRead: no path-addressed API — a macro can only write to a target the user picks through the OS save picker. - Application.OpenFileForWriting([filter]) gates the FileWrite capability, raises the save picker via a new MacroBackend::pick_write_target seam (the chosen target is the second consent), and returns a run-scoped write handle. A cancelled pick is a trappable error. - Handle members: .Write / .WriteLine (.Print) buffer text; .Close flushes the buffer to the picked path through MacroBackend::write_file. A failed flush is a trappable error (new FileWriteError); an unclosed handle writes nothing (explicit-close contract). Both backend seams default to no-op/refuse, so UDF/headless/tests write no files. - WRITE_FILE_BASE is a third, highest object-handle range (dispatched first). The DocFacade struct + handle tables moved to exec/doc_facade.rs to keep exec/mod.rs under the 300-line ceiling. tests/file_tests.rs now 10 cases (read + write): buffered Write/WriteLine flush on Close, denied FileWrite never raises the picker, cancelled save pick is trappable, a write failure is trappable, and an unclosed handle is not flushed. Both feature configs pass clippy -D warnings and tests; gates green; workspace builds. Still deferred: the loki-text app-side picker bridge (the read + write pickers are already async there, so it is wiring, not new infra). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Completes Phase 7B — macro file access now works end-to-end in the word processor. The macro-host core (OpenFileForReading/OpenFileForWriting + the read_file/pick_write_target/write_file seams) is unchanged; this wires the real OS picker + platform file I/O into it. - editor_macro_bridge: three new UiRequest variants (PickReadFile, PickWriteTarget, WriteFile) + matching UiReply (ReadFile, WritePath, WriteResult). BridgeBackend implements the three MacroBackend file seams by posting over the existing worker↔UI bridge and blocking for the reply. - editor_macro_file_pick (new): the runner's drain loop services a file request inline — it drives the async loki_file_access::FilePicker and does byte I/O through the platform FileAccessToken (open_read/open_write), both on the UI thread (Android content-URI tokens used on the right thread; no path-addressed std::fs). FileFilter → picker MIME types is best-effort/advisory; the user's pick is the authority. - The drain loop hands file requests to try_handle_file_request; everything else still renders as a prompt component. from_request/answer_prompt gain inert arms for the (never-rendered) file requests. Threaded bridge round-trip tests cover both directions (read a canned file into the doc; buffered write reaches write_file with the exact path+bytes on Close). Both feature configs pass clippy -D warnings and tests; gates green; workspace builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Makes the macro Network capability reachable end-to-end (ADR-0015 §8). The capability was gated behind BOTH the macro-net build feature and an "off-by-default runtime setting" — the runtime half was unbuilt, so network could never actually turn on. This adds it as a per-document opt-in, mirroring the existing auto_run_open precedent. - TrustRecord.allow_network (serde-defaulted for back-compat) + MacroService::network_enabled / set_allow_network (requires a persistent trust record; per-origin prompts still gate every request). DocumentSecurity snapshot carries allow_network. - loki-text make_run_request enables the run's NetworkPolicy only when MACRO_NET_COMPILED && svc.network_enabled(payload); otherwise it stays disabled and every HttpGet is an untrappable refusal. - Document Security panel: an "Allow network access" checkbox (sibling of auto-run) shown only when the feature is compiled and the document is trusted. macros-security-network* i18n. - The per-document opt-in cluster (auto-run + network) moved to service/opt_in.rs to keep service/mod.rs under the 300-line ceiling. Round-trip tested (network_enabled_defaults_off_and_requires_record: off by default, no-op without a record, sticks with trust, independent of auto-run). Both feature configs pass clippy -D warnings and tests; gates green; workspace builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
The four open Track A gates (8A.3/8A.4-corpus, 8A.8-vba-content, 8A.6-tsa-anchor/xades) are all blocked on the same thing: a corpus of real third-party-signed documents. Every current signature test builds its own fixture in-process (fresh rcgen keys, our own canonicaliser producing the signed octets), which proves self-consistency but cannot prove agreement with Word or LibreOffice — which is the entire point of a verifier. docs/macro-signature-corpus.md records what is needed to close them: - The sample matrix (VBA V-1..V-7, ODF O-1..O-6, timestamps T-1..T-3), with how to produce each in Word/LibreOffice, including the negatives (edited-after-signing must read Invalid) that prove the content hash is load-bearing rather than vacuous. - The commit constraint: a signer's certificate carries real identity and a signature cannot be redacted, so self-produced samples (fictitious subject) are committed under assets/ per the loki-acid precedent while vendor-signed ones stay local behind LOKI_MACRO_CORPUS. - The per-gate unblock order, including the ADR-0014 §6 decision point (fall back to rasn-cms if a real SignedData quirk defeats RustCrypto). - An explicit warning not to implement these from the spec alone: an unvalidated content-hash normalisation reports every real signed document as Invalid, which is worse than today's honest Unsigned. Linked from the fidelity-status §13 residual-gates note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Seven findings from the review of 16be9bd..HEAD (Phase 8B network + Phase 7B picker-mediated file access). Correctness: - Object-handle collision (doc_facade): the handle bases are 0x1000 apart and the tables were unbounded, so the 4097th HttpGet response minted FILE_HANDLE_BASE and facade::get_member — which dispatches highest-base-first — read it back as a picked file. Tables are now capped at MAX_OBJECTS_PER_KIND, and the disjointness is enforced by a `const _: () = assert!(..)` so raising the cap past the spacing fails the build rather than silently corrupting dispatch. - Unbounded retention (doc_facade): each response/file is individually capped but the count was not, so a fetch loop over one granted origin could exhaust memory. Added a run-wide MAX_RETAINED_BYTES budget charged by responses, picked files, and `.Write` buffers; exhaustion is a trappable error. - Failed `.Close` was not retryable (exec/file): `closed` was set before the write, so after a failed flush a second `.Close` returned success without writing and an On Error-retrying macro believed the file was saved. Only a successful flush closes the handle now. - Origin/redirect normalization mismatch (http): `origin_of` hand-parsed the authority while redirect targets resolved through `url::Url`, which strips the scheme-default port and punycodes IDN hosts — so a grant taken on an explicit `:443` URL did not match the origin of its own same-host redirect. `origin_of` now uses the same parser the client uses; an origin check that parses differently from the code issuing the request is how origin checks get bypassed. - Opaque token leaked as a path (file_pick + file): `.Path`/`.Name` returned `FileAccessToken::serialize()` — URL-safe base64 of the token's JSON, on Android a content-URI permission grant. Split into a macro-visible `display_name` and an opaque `handle` (new `WriteTarget`; `PickedFile.path` renamed to `display_name` so the contract is self-documenting). - Stale network opt-in (service/opt_in): `network_enabled` lacked the `decision.is_enabled()` check its sibling `authorize_auto_run` has, and `upsert_decision` preserves the flag across a decision change — so a document later set to "Keep disabled" kept a live opt-in the panel gives no way to revoke. Docs: - net_policy: the TODO(8B.4) still described the read-then-measure cap that 8B.4 replaced with the streaming read_body_capped in the same file. Regression tests: default-port + IDN origin equality, failed-close retries (and successful close stays idempotent), disabled document drops the network opt-in, `.Path` is the display name. Both feature configs pass clippy -D warnings and tests; all gates green; workspace builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
CI's lint job failed on `loki-macro-sig/src/verify.rs:155`: `.map(f).unwrap_or(0)` on the `Result` from `duration_since` should be `.map_or(0, f)`. Semantically identical; clippy's own suggested rewrite. Why local checks missed it: CI installs `dtolnay/rust-toolchain@stable` and the repo pins no `rust-toolchain.toml`, so CI floats on latest stable (1.97.1) while this sandbox had 1.94.1. `map_unwrap_or` was extended to `Result` receivers in between — the `Option` form this workspace already uses elsewhere was clean under both, so nothing fired locally. CI also runs `--workspace --all-features`, broader than a per-crate invocation. The local toolchain is now 1.97.1 (clippy 0.1.97), matching CI, and a full `cargo clippy --workspace --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used` is running to catch anything CI's fail-fast hid behind this first error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
CI fails fast, so these were hidden behind the first error.
- loki-basic/src/parser/mod.rs: `peek()` used `.expect("token buffer always
contains Eof")` — a genuine CLAUDE.md violation (no `.expect()` in library
code) that CI catches via its extra `-D clippy::expect_used`, which my
per-crate runs omitted. Made total instead of allow-listed: the cursor
falls back to the trailing `Eof` token (preserving the real end-of-source
span, so diagnostics are unchanged), with a `static EOF_TOKEN` covering
only the empty-buffer case the lexer never produces.
- loki-macro-host/src/service/summary.rs: `sort_by(|a, b| b.x.cmp(&a.x))` →
`sort_by_key(|r| Reverse(r.last_used))`. Another 1.97 lint; also states
"newest first" more directly.
Local toolchain is now at CI parity (1.97.1) and a full
`cargo clippy --workspace --all-features` with CI's extra deny flags is
running to confirm nothing remains downstream — the earlier run halted at
loki-macro-host before reaching loki-text and the other leaf crates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
loki-text/src/routes/editor/editor_macro_runner.rs: the live-prompt block
guarded with `if pending.read().is_some()` and then re-read the signal with
`.expect("pending is Some")` — an unreachable panic, but still a panicking
accessor in library code (CLAUDE.md) and caught by CI's extra
`-D clippy::expect_used`.
Replaced the guard+expect pair with a single `if let`. The owned view is
bound in its own `let` statement so the signal borrow is definitively
released before the body runs — the answer callback writes the same signal,
and relying on edition-2024 `if let` temporary-drop rules for that would be
a fragile way to avoid a runtime deadlock.
Verified at CI parity (toolchain 1.97.1): the full
`cargo clippy --workspace --all-features -- -D warnings
-D clippy::unwrap_used -D clippy::expect_used` now exits 0, as do
`cargo fmt --all --check` and all nine gate scripts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Root-cause fix for the four lint failures on this PR. CI installed `dtolnay/rust-toolchain@stable` — floating on whatever stable is current (1.97.1) — while this sandbox sat on 1.94.1. Clippy widens lints between releases, so CI failed on lints a local `cargo clippy` could not possibly report: `map_unwrap_or` gained `Result` coverage, and `sort_by_key` fired on a `sort_by` that 1.94 accepted. That drift is only discoverable by pushing, which turns it into a slow fail-push-repeat loop. - rust-toolchain.toml pins 1.97.1 (the version CI already resolved to, so this locks in the currently-green state rather than moving anything) plus the rustfmt/clippy components. rustup honours it for every invocation in the repo, so local and CI now run the same compiler and the same lints. - .github/workflows/rust.yml names the same version in both jobs instead of `@stable`. The file is the real pin — cargo honours it regardless — so this just avoids installing a second, unused toolchain. Also fixes the *other* half of the gap, which the pin alone would not have caught: CLAUDE.md told contributors to run `cargo clippy --workspace -- -D warnings`, but CI runs `--all-features` with two extra deny flags (`-D clippy::unwrap_used -D clippy::expect_used`). Running the documented command was therefore insufficient by construction, and it is why two `.expect()` calls in library code — a stated CLAUDE.md prohibition — reached CI. The doc now carries CI's exact command and notes that clippy.toml exempts test code. Verified: rustup reports 1.97.1 "overridden by rust-toolchain.toml"; cargo and clippy resolve to the pinned versions; the workflow YAML parses and both jobs reference the pinned toolchain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the macro-scripting suite per
LOKI_MACRO_SCRIPTING_SPEC.md, ADR-0014 (signatures/trusted publishers) and ADR-0015 (network capability).67 commits. The organising principle throughout: macros are disabled by default and the interpreter has no authority of its own — every effect passes through a capability broker, and nothing in a document can grant itself trust.
What's here
MacroPayloadon the provenance layer (not the CRDT); DOCX/XLSX VBA and ODFBasic/+Scripts/round-trip byte-identically; "macros dropped" warning on lossy conversion; passive infobar.loki-basic— pure-Rust tree-walking interpreter for VBA + StarBasic over one AST. No JIT (iOS-safe), fuel-metered, no ambient I/O. Conformance suite + 3 fuzz targets + a CI purity gate.loki-vba— MS-OVBA decompression, CFB/dir-stream parse, source extraction, p-code stomping heuristic. Read-only macro viewer.loki-macro-host— closed capability catalog, trust store keyed by payload hash (T10: trust never inferred from content), capability broker, anti-spoof permission prompts.#MACRO!), MACROBUTTON click-to-run.OpenFileForReading/OpenFileForWriting. No path-addressed API exists — the user's pick is the grant (T3).loki-macro-sig, L2): CMS/PKCS#7 + X.509, ODF XMLDSig with an inclusive C14N implementation, RFC-3161 timestamps, trusted-publisher pinning. Verification proves authorship; only a user-pinned publisher yields trust.reqwest/rustlstransport behind an off-by-default build feature and a per-document runtime opt-in.Security posture
loki-macro-hostat all (CI gate), and every non-interactive run uses a backend whose effects refuse.Authorizationis deliberately kept — it's the author's own).Testing
~400 tests across the macro crates: unit, end-to-end runs against a mock backend, threaded worker↔UI bridge tests, and 6 fuzz targets. Both
macro-netfeature configurations passclippy --all-targets -D warningsand the full suite; all 8 CI gate scripts green.Known gaps — deliberate, documented, not blocking
Unsignedrather than a misleadingInvalid. The gathering checklist isdocs/macro-signature-corpus.md— it deliberately warns against implementing the content-hash from the spec alone, since an unvalidated normalisation would report every real signed document as tampered.docs/macro-editor-verification.md.TODO(<topic>)markers: cross-run session-origin memory, per-run network timeout, punycode/homograph origin display,HttpPost(deferred by ADR-0015).Review notes
.Close, an opaque platform token exposed as a macro-visible path, unbounded response retention, a stale network opt-in surviving a trust downgrade, one stale TODO) — all fixed in59edb67with regression tests.mainmid-development, so the diff is macro work only.docs/fidelity-status.md§13 is the living registry for what's wired vs. pending.🤖 Generated with Claude Code
https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB
Generated by Claude Code