fvtt-world-cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/protocol.md CHANGED
@@ -15,15 +15,18 @@ size limits are defined by the protocol package and discoverable at runtime:
15
15
 
16
16
  ## Versioning
17
17
 
18
- The protocol is versioned. Every transport message carries the protocol version, and an unsupported
19
- version is rejected at the bridge handshake and during command handling rather than degraded
20
- silently. The current version is `3.0`; matching is exact, so a daemon and a Foundry module from
21
- different releases refuse each other at the handshake instead of negotiating a subset.
18
+ The protocol version equals the product release version. Every transport message carries it, and all
19
+ components in one installation must match exactly. The daemon and Foundry module reject a mismatch
20
+ instead of negotiating a subset of the contract.
21
+
22
+ Recover by updating the older component. Restart the daemon after updating the CLI package, or reload
23
+ the GM client after updating the Foundry module. A refused module load does not reconnect on its own.
24
+ Mismatch errors identify the older component when the two versions can be ordered.
22
25
 
23
26
  ## Transport model
24
27
 
25
28
  The daemon listens on a loopback WebSocket endpoint. Credentials never appear in URLs. A local
26
- client the CLI or a future Companion has no browser Origin and must establish its role with its
29
+ client, the CLI or a future Companion, has no browser Origin and must establish its role with its
27
30
  first message: the daemon enforces a short deadline for a valid first frame, closes malformed
28
31
  openings immediately, and never assigns a role before authentication has succeeded. Browser sockets
29
32
  are identified by their exact HTTP(S) Origin and participate only in pairing and bridge sessions;
@@ -36,7 +39,7 @@ a malformed control request receives a correlated error and may keep using its c
36
39
  The daemon accepts one active authenticated bridge at a time:
37
40
 
38
41
  - another pairing cannot displace the active bridge and is rejected as `BRIDGE_BUSY`;
39
- - a new socket from the same pairing takes the slot over the tab-reload recovery path;
42
+ - a new socket from the same pairing takes the slot over, the tab-reload recovery path;
40
43
  - a clean goodbye releases the slot immediately, while an abnormal close reserves it briefly for
41
44
  the same pairing;
42
45
  - a daemon-initiated release is terminal for the released client: it does not reconnect on its
@@ -56,13 +59,13 @@ applied to the transport only after the daemon restarts.
56
59
  Pairing is the one-time exchange that lets a GM browser become a bridge. Its guarantees:
57
60
 
58
61
  - a pending request is bound to the socket that made it and disappears when that socket closes;
59
- - approval codes expire after a bounded interval;
62
+ - pairing codes expire after a bounded interval;
60
63
  - the daemon persists only a digest of the bridge credential; the clear credential is delivered
61
64
  exactly once, to the requesting socket, only after the digest has been persisted;
62
65
  - approving the same Origin/world/user/client again re-pairs that client's existing profile by
63
66
  rotating its credential instead of accumulating duplicates;
64
- - expiry, denial, socket close, and approval all end the attempt through one idempotent path, so
65
- the browser-side authorization UI is never left waiting after daemon shutdown or expiry.
67
+ - expiry, denial, socket close, and a granted pairing all end the attempt through one idempotent
68
+ path, so the browser-side authorization UI is never left waiting after daemon shutdown or expiry.
66
69
 
67
70
  ### Client identity
68
71
 
@@ -82,7 +85,7 @@ independent records and neither re-pair disturbs the other.
82
85
 
83
86
  The bridge hello carries `clientId` at the top level, beside `pairingId` and `credential`, because it
84
87
  is authentication material rather than session content: the daemon rejects a hello whose client id
85
- does not match the stored pairing. The label is not resent on hello the daemon's pairing record owns
88
+ does not match the stored pairing. The label is not resent on hello; the daemon's pairing record owns
86
89
  it, and the browser keeps a copy only to display it.
87
90
 
88
91
  ## Daemon control
@@ -110,8 +113,8 @@ never stamps it. The pairing that owns the active bridge, and the holder of a li
110
113
  abnormal-disconnect lease, are excluded from removal regardless of their timestamps, so pruning can
111
114
  never unpair the browser that is connected or the one the daemon is still holding a slot for. The
112
115
  config is rewritten only when at least one record is removed. The result is `{ olderThanDays, pruned }`,
113
- where `olderThanDays` is the cutoff the daemon applied including the default when the caller omitted
114
- it and `pruned` carries the removed records in the same public, digest-free shape `auth.list`
116
+ where `olderThanDays` is the cutoff the daemon applied, including the default when the caller omitted
117
+ it, and `pruned` carries the removed records in the same public, digest-free shape `auth.list`
115
118
  serializes. The daemon computes the set at execution time; a caller that previewed candidates from
116
119
  `auth.list` holds an advisory list, not the outcome, and the executed set may be wider than that
117
120
  preview when a record crosses the cutoff between the two calls.
@@ -153,6 +156,12 @@ exclusive: success carries a result, failure carries a structured error.
153
156
 
154
157
  Document results live under a type-named key inside the result. Collection, action, and bulk
155
158
  results use their documented keys, which vary between commands but are stable for each one.
159
+ Broadcast commands that change no document, pulling users to a scene and showing a journal entry
160
+ or image, report `dispatched` rather than a confirmed post-state, because a socket broadcast offers
161
+ nothing to read back; the result names the users it targeted and the active/inactive split where
162
+ that is knowable. `macro.execute` reports the macro's returned value and observed chat messages,
163
+ and a timeout there is indeterminate: the macro keeps running in the GM client, so `MACRO_TIMEOUT`
164
+ callers verify effects by reads instead of retrying blindly.
156
165
  Serialized projections expose `id` as the public identifier; a source `_id` mirror may accompany
157
166
  it. A previewed new document has no persistent identity, and an id observed during a preview must
158
167
  not be reused. List-like responses that paginate return their collection with a total and a
@@ -164,7 +173,7 @@ Mutation commands accept a dry-run request that passes through validation, resol
164
173
  sanitization, permission checks, capability checks, and preparation, then returns before
165
174
  persistence using the normal result shape with an explicit dry-run marker. Only values knowable
166
175
  before execution are reported: random selection, rendering, hooks, and other execution-dependent
167
- observations may be absent or explicitly unconfirmed. A successful preview reserves nothing the
176
+ observations may be absent or explicitly unconfirmed. A successful preview reserves nothing; the
168
177
  world can change between preview and commit.
169
178
 
170
179
  ## Idempotency
@@ -172,9 +181,9 @@ world can change between preview and commit.
172
181
  Commands with duplicate-creation or non-repeatable-action risk accept or require an idempotency
173
182
  key identifying one logical request. Reusing a key with a different command or payload is rejected
174
183
  as `IDEMPOTENCY_KEY_CONFLICT`. Idempotency memory is runtime state: bounded, and cleared by daemon
175
- restart, bridge replacement, world switch, expiry, or eviction. It reduces duplicate effects across
176
- response loss; it is not a durable transaction, so an indeterminate delivery still ends with a
177
- world-state read.
184
+ restart, bridge replacement, world switch, or expiry. The daemon may also evict cached successes.
185
+ Idempotency reduces duplicate effects across response loss. It is not a durable transaction, so an
186
+ indeterminate delivery still ends with a world-state read.
178
187
 
179
188
  ## Batch requests and bulk writes
180
189
 
@@ -184,6 +193,12 @@ commands over bounded arrays: elements are prevalidated, but Foundry persistence
184
193
  transactional, so the result reports overall completeness plus a per-element outcome, and every
185
194
  outcome carries its own meaning.
186
195
 
196
+ Batch reads (`get-many`) fail the whole request when any requested id cannot be read, with one
197
+ exception: `setting.get-many` reports an unregistered key on its own result row (`SETTING_NOT_FOUND`)
198
+ rather than failing the request, because a world routinely registers settings only for the systems
199
+ and modules it has active, so a partially resolvable set of keys is the normal case rather than an
200
+ error.
201
+
187
202
  ## Error model
188
203
 
189
204
  Errors have a stable code, a human-readable message, and optional structured details. The code and
@@ -195,21 +210,90 @@ code set is exported by the protocol package. The classes consumers act on:
195
210
  | Request/schema | `INVALID_PARAMS`, `UNKNOWN_COMMAND` | Correct the request or resolve version skew |
196
211
  | Authentication/permission | `UNAUTHORIZED`, `PERMISSION_DENIED` | Restore credentials or authority |
197
212
  | Pairing | `PAIRING_REQUIRED`, `PAIRING_EXPIRED`, `BRIDGE_BUSY` | Pair, retry revocation, or release the active owner as indicated |
198
- | Lookup | `*_NOT_FOUND` | Refresh ids and world state |
199
- | Safety/policy | `DELETE_FORBIDDEN`, `PATH_NOT_ALLOWED` | Change the requested operation |
213
+ | Lookup | `*_NOT_FOUND`, `SETTING_UNREGISTERED` | Refresh ids and world state |
214
+ | Safety/policy | `DELETE_FORBIDDEN`, `PATH_NOT_ALLOWED`, `SETTING_PROTECTED`, `USER_SELF_PROTECTED` | Change the requested operation |
200
215
  | Capability | `UNSUPPORTED_OPERATION` | Choose a supported workflow or runtime |
201
- | Size/resource | `PAYLOAD_TOO_LARGE`, `QUERY_TOO_BROAD` | Reduce or page the request |
216
+ | Size/resource | `PAYLOAD_TOO_LARGE`, `QUERY_TOO_BROAD`, `IDEMPOTENCY_STORE_FULL` | Reduce, page, or resend the request later |
217
+ | Command policy | `COMMAND_DENIED` | Treat the command as unavailable on that GM client |
218
+ | Approval | `APPROVAL_PENDING`, `APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_CANCELLED`, `APPROVAL_QUEUE_FULL`, `APPROVAL_UNKNOWN` | Apply the approval rules below |
202
219
  | Bridge state | `BRIDGE_NOT_READY`, `BRIDGE_TIMEOUT`, `BRIDGE_DISCONNECTED` | Apply the delivery rules below |
220
+ | Indeterminate outcome | `MACRO_TIMEOUT` | Verify the effect by reads before retrying |
203
221
  | Unexpected | `INTERNAL_ERROR` | Preserve details and investigate |
204
222
 
205
223
  Foundry DataModel validation failures surface as parameter errors and are distinguished in details
206
224
  where available. A failed nested lookup identifies the level that failed.
207
225
 
226
+ `UNSUPPORTED_PROTOCOL_VERSION` details carry `expectedVersion`, `actualVersion`, the rejecting
227
+ `handshake`, and `staleComponent`. The component is `module`, `cli-daemon`, or `unknown`. Consumers
228
+ can name the component that needs an update when the comparison identifies it. An unordered version
229
+ or unidentified peer produces `unknown`.
230
+
208
231
  With JSON output, a failed command emits one structured error envelope on stdout and exits
209
232
  non-zero. The exit code is a coarse process classification; the structured error code is the
210
233
  authoritative automation signal. `exec --stdin` reports per-line errors and uses its own aggregate
211
234
  exit status.
212
235
 
236
+ ## Approval flow
237
+
238
+ A GM client's command policy can require approval before a command runs. Approval is the GM's
239
+ decision about one invocation. Pairing approval grants a browser credential, while confirmation
240
+ checks a completed write. The active GM client's policy controls the invocation.
241
+
242
+ The wait has two phases because the decision can outlast a normal request timeout:
243
+
244
+ - The original request returns `APPROVAL_PENDING`. Its details contain `approvalId`, `expiresAt` in
245
+ epoch milliseconds, and `command`. A consumer without approval support stops on this error. The CLI
246
+ converts it into a blocking wait.
247
+ - `approval.await { approvalId, waitMs? }` asks for the current state. `waitMs` cannot exceed
248
+ `APPROVAL_AWAIT_PARK_CAP_MS`. The result is either
249
+ `{ approvalId, status: "pending", expiresAt? }` or
250
+ `{ approvalId, status: "resolved", outcome, response? }`. Once execution starts, the approval can
251
+ no longer expire.
252
+ - Terminal outcomes are `approved`, `denied`, `timeout`, and `cancelled`. An approved outcome carries
253
+ the command response, including handler errors, and uses the approval identifier as its envelope
254
+ `id`. The other outcomes mean the command did not run. The CLI reports them as `APPROVAL_DENIED`,
255
+ `APPROVAL_TIMEOUT`, or `APPROVAL_CANCELLED`.
256
+ - The module retains terminal outcomes for bounded repeat reads. It may discard an outcome after a
257
+ client has read it, but it does not discard an unread outcome to admit a new request. A later read
258
+ of discarded state returns `APPROVAL_UNKNOWN`.
259
+ - `approval.cancel { approvalId }` returns `cancelled`, `executing`, `resolved`, or `unknown`. Only
260
+ `cancelled` proves that the command will not run. Use `approval.await` after `resolved` to read the
261
+ decision.
262
+ - `APPROVAL_QUEUE_FULL` means the module refused admission before display or execution. Its `reason`
263
+ is `pending-count`, `pending-bytes`, or `retained-count`. The request is safe to retry after earlier
264
+ approvals clear.
265
+ - `APPROVAL_UNKNOWN` means the module no longer holds that approval. Reloading the GM client, ending
266
+ its bridge session, or expiry can remove the state. The command may not have started, or it may have
267
+ completed. Read world state before another write.
268
+ - A dry run bypasses approval and reports `approvalRequired: true` when the real command would wait.
269
+ The policy still refuses denied commands during a dry run.
270
+ - `policy.snapshot` reports `{ approve: [names], deny: [names] }`. The result is advisory because the
271
+ policy can change before dispatch.
272
+
273
+ The module supplies each opaque `approvalId`; callers do not construct one. Approval request schemas
274
+ are closed. `approval.await`, `approval.cancel`, and `policy.snapshot` do not appear in `commands`,
275
+ `system.info` command inventory, or bridge status. `schema <command>` still returns their schemas.
276
+ The bridge handshake advertises them because the daemon must forward them.
277
+
278
+ An idempotency key covers the request and its approval:
279
+
280
+ - After `APPROVAL_PENDING`, the daemon links the key to that approval. A byte-identical retry returns
281
+ the same pending response. A different request with that key returns
282
+ `IDEMPOTENCY_KEY_CONFLICT`.
283
+ - An approved outcome becomes the cached final response. A denial, timeout, or confirmed
284
+ cancellation removes the link, so the same request can start a new approval.
285
+ - If the daemon cannot read the approval outcome, the key remains indeterminate and returns
286
+ `APPROVAL_UNKNOWN` until expiry. Read world state before retrying under a fresh key.
287
+ - If a bridge session ends before the daemon receives the first response, the daemon retains the key
288
+ as lost in flight. Reuse returns `BRIDGE_DISCONNECTED` with `reason: "lost-in-flight"`. Read world
289
+ state, then use a fresh key if the operation still needs to run.
290
+ - The daemon reserves bounded space before forwarding a keyed request. If no slot is available, it
291
+ returns `IDEMPOTENCY_STORE_FULL` before Foundry receives the request. Retry after earlier keys
292
+ settle or expire.
293
+ - Daemon restart, world switch, pairing switch, and expiry clear runtime idempotency state. A later
294
+ request can reach Foundry as a new operation, so an indeterminate result still requires a state
295
+ read first.
296
+
213
297
  ## Delivery states and retries
214
298
 
215
299
  Retry safety is a function of whether the request reached Foundry:
@@ -219,8 +303,13 @@ Retry safety is a function of whether the request reached Foundry:
219
303
  | Client could not connect | No | Safe to retry after restoring the daemon |
220
304
  | `BRIDGE_NOT_READY` | No | Safe to retry after a bridge connects |
221
305
  | Response timeout after send | Possibly | May have committed; inspect state or reuse the same idempotency key |
222
- | `BRIDGE_TIMEOUT` | Yes | May have committed; inspect state or reuse the same idempotency key |
223
- | `BRIDGE_DISCONNECTED` | Yes or in flight | May have committed; inspect state |
306
+ | `BRIDGE_TIMEOUT` | Yes | May have committed; inspect state or reuse the same idempotency key while that bridge session lasts |
307
+ | `BRIDGE_DISCONNECTED` | Yes or in flight | May have committed; inspect state, then re-request under a fresh idempotency key |
308
+ | `COMMAND_DENIED` | Refused before dispatch | Not executed; the command is unavailable on that GM client |
309
+ | `APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_CANCELLED` | Reached Foundry, never dispatched | Not executed; the same request is safe to send again |
310
+ | `APPROVAL_QUEUE_FULL` | Refused before admission | Not executed; safe to retry when the waiting decisions clear |
311
+ | `IDEMPOTENCY_STORE_FULL` | No | Not executed; safe to retry when earlier keys settle or expire |
312
+ | `APPROVAL_UNKNOWN` | Unknown | May have committed; inspect state, then re-request under a fresh idempotency key |
224
313
  | Structured command rejection | Resolved with an error | Correct according to the code |
225
314
 
226
315
  The distinction between connection-phase and response-wait failures is carried in structured error
@@ -229,8 +318,10 @@ protocol and CLI constants; runtime flags can override the client and daemon req
229
318
 
230
319
  ## Compatibility rules
231
320
 
232
- - Additive result and handshake fields are preferred for compatible evolution.
233
- - Request schemas remain explicit and versioned.
321
+ - One release ships the CLI, daemon, and Foundry module as a compatible set. They share that release's
322
+ version. The bridge refuses mixed-release operation and does not negotiate a subset.
323
+ - Within a release line, additive result and handshake fields are how the contract evolves without
324
+ changing the meaning of existing fields; request schemas remain explicit and versioned.
234
325
  - A bridge advertises the command set it can execute; the daemon forwards only advertised commands.
235
326
  - Unsupported version-dependent behavior produces a predictable error rather than a false success.
236
327
 
package/docs/security.md CHANGED
@@ -33,15 +33,16 @@ public multi-tenant API.
33
33
  storage is shared by every tab of one browser, so a first-use identifier can be generated twice when
34
34
  two tabs start pairing at once; the browser discards an approved credential whose identifier no longer
35
35
  matches the stored one instead of keeping a pairing the hello gate would refuse.
36
- - The browser label attached to a pairing is constrained by the protocol schema 1 to 64 characters,
37
- with whitespace-only values rejected along with control, zero-width, bidirectional-override, and
38
- Unicode tag characters — and a violating request is refused rather than repaired. Rejecting a
39
- whitespace-only label removes the simplest way to send a label that reads as nothing; it is not a
40
- guarantee that a label renders visibly, because blank-rendering filler characters pass the pattern.
36
+ - The browser label attached to a pairing is constrained by the protocol schema to 1 to 64
37
+ characters, with whitespace-only values rejected along with control, zero-width,
38
+ bidirectional-override, and Unicode tag characters; a violating request is refused rather than
39
+ repaired. Rejecting a whitespace-only label removes the simplest way to send a label that reads
40
+ as nothing; it is not a guarantee that a label renders visibly, because blank-rendering filler
41
+ characters pass the pattern.
41
42
  The client identifier printed beside the label is what reliably distinguishes two browsers, including
42
43
  two that share one label. The pairing socket is unauthenticated, so any local process can send a
43
44
  pairing request, and an unfiltered label would reach a terminal as an escape-sequence injection or as
44
- hidden text in agent output: both approval prompts, the one `auth approve` shows and the one the
45
+ hidden text in agent output. Both approval prompts, the one `auth approve` shows and the one the
45
46
  `auth` pairing wait shows, print it as raw text, and JSON output escapes C0 control characters but
46
47
  not C1, zero-width, bidirectional-override, or tag ones. The constraint covers the label field only;
47
48
  the identity fields printed beside it are described under Known risks.
@@ -52,7 +53,9 @@ public multi-tenant API.
52
53
  any other local process is still refused rather than repaired.
53
54
  - Browser pairing and bridge sockets require a syntactically valid HTTP(S) Origin, matched exactly
54
55
  after pairing. Credentials never appear in URLs, command lines, environment variables, or output.
55
- - The bridge starts only for a configured GM client.
56
+ - The bridge starts only for a configured GM client. The module hides its settings and category from
57
+ users below the Assistant GM role, and only gamemasters can open its windows. The settings remain
58
+ registered in that browser profile for a later GM session.
56
59
  - Another pairing cannot displace the active bridge. A same-pairing reconnect may take over after a
57
60
  reload; requests already forwarded to the displaced socket fail immediately as indeterminate.
58
61
  Abnormal close retains a 30-second lease against other pairings, while intentional goodbye,
@@ -79,7 +82,7 @@ runtime address does not broaden that authority or invalidate an explicitly conf
79
82
 
80
83
  The bridge accepts only explicit commands registered in the protocol and advertised during the
81
84
  handshake. Each command has a schema and a known handler. There is no generic method invocation or
82
- universal edit anything endpoint.
85
+ universal "edit anything" endpoint.
83
86
 
84
87
  Mutations use Foundry Document APIs or a reviewed typed Foundry action. Raw live world databases are
85
88
  outside the command surface.
@@ -106,6 +109,43 @@ Foundry hooks can veto or partially apply writes. Mutation handlers confirm obse
106
109
  where the command contract requires it and return structured partial or failed outcomes instead of
107
110
  claiming success.
108
111
 
112
+ Every command also carries an allow, approve, or deny permission in the GM client holding the
113
+ bridge. The Foundry module enforces it after authentication, GM authority, and write-permission
114
+ checks but before dispatch. It covers reads, writes, bulk envelopes, and previews. The default
115
+ policy denies commands that can execute code, change who can do what, or persist outside the
116
+ world's own data; requires approval for destructive commands plus `system.reload` and
117
+ `user.create`, where the destructive set is the `delete` and `delete-many` verbs, `file.move`,
118
+ `scene.fog.reset`, and `chat.flush`; and allows the other policy-controlled commands. A
119
+ denied-by-default command is a deliberate opt-in: it exists so a GM can enable it knowingly, not
120
+ so it can run out of the box.
121
+
122
+ `system.ping`, `system.info`, and the internal approval-wait commands always run. They let the bridge
123
+ report its state and finish decisions already taken without changing world content. Stored policy
124
+ overrides cannot change them, and the permissions window omits them. Pairing and other `auth`
125
+ operations run in the daemon, so the command policy does not apply.
126
+
127
+ A denied command is absent from the CLI's `commands` listing while the bridge is reachable. This
128
+ reduces irrelevant choices for automated callers but does not enforce the policy. The Foundry module
129
+ enforces it at dispatch and returns `COMMAND_DENIED` if a caller sends the command anyway.
130
+
131
+ The original request does not execute a command that requires approval. The module holds it in
132
+ memory until the GM decides. After Allow, the module repeats readiness, GM authority, parameter,
133
+ write-permission, policy, and family checks. A command changed to deny while waiting returns
134
+ `COMMAND_DENIED`. Changing it between allow and approve does not create a second approval. A denied,
135
+ expired, or confirmed-cancelled request never runs.
136
+
137
+ The module returns a random 128-bit `approvalId` only to the original caller. Reading or cancelling
138
+ the decision requires that identifier.
139
+
140
+ The GM-only Command Approval window shows the command, remaining time, target documents or managed
141
+ paths, and parameters. It reports binary upload content by size instead of rendering the payload,
142
+ and any text longer than 16,384 characters, including a macro body, by its character count for the
143
+ same reason. A macro long enough to cross that line is therefore approved on its name and origin
144
+ rather than on its code, and `macro.create` runs by itself under the shipped defaults, so a GM who
145
+ wants every body reviewable moves `macro.create` and `macro.update` to approve as well. The command
146
+ envelope has no caller identity, so the window cannot name the requester. The GM approves the
147
+ displayed invocation, not a person or process.
148
+
109
149
  ## Document ownership
110
150
 
111
151
  Ownership is access policy rather than ordinary document content. Raw `ownership` is excluded from
@@ -119,13 +159,36 @@ family's behavior.
119
159
 
120
160
  ## Executable content
121
161
 
122
- CLI-supplied data cannot create an arbitrary JavaScript execution path through the bridge.
123
-
124
- - Macro bodies and chat content can be stored but are not executed or routed through command
125
- processors.
162
+ The bridge executes no JavaScript that the GM has not explicitly enabled and cannot see before it
163
+ runs. There is no `eval`-style command, and CLI-supplied data cannot create a hidden execution path
164
+ through ordinary writes.
165
+
166
+ - `macro.execute` is the one way to run code, and it runs only stored world macros. It is denied by
167
+ default; a GM who enables it can keep it on approve, where the Command Approval window shows the
168
+ macro's type and its command body, up to the length cap described above, before anything runs.
169
+ The `macro.create → macro.execute → macro.delete` chain is the sanctioned path for ad-hoc code,
170
+ so a GM who wants only vetted macros to run sets `macro.create` and `macro.update` to approve or
171
+ deny while `macro.execute` stays enabled. A script macro that throws fails the command with a
172
+ structured error naming what the macro raised, and the failure is partial by nature: whatever
173
+ the macro changed before it threw stays changed. A macro that catches its own errors still
174
+ reports a `null` return, so results do not prove success and effects deserve a read-back.
175
+ - Macro bodies and chat content written by ordinary document commands are stored, not executed.
126
176
  - Action commands invoke only their fixed typed Foundry methods.
127
- - Writes that supply core script- or macro-executing RegionBehavior types are rejected through a
128
- shared guard.
177
+ - Ordinary region-behavior writes reject core script- or macro-executing types through a shared
178
+ guard, including on nested behaviors supplied with a region write. The dedicated
179
+ `scene.region.behavior.executable` commands, denied by default, accept exactly the `executeMacro`
180
+ type and require the referenced world macro to exist; the approval window names the macro, the
181
+ trigger events, and whether the behavior fires for everyone. Those commands accept a behavior's
182
+ `system` only in a spelling the guard and that window can both read: a create takes only the plain
183
+ `system` object, an update or clone also accepts single-field `system.<field>` paths, and no route
184
+ accepts both spellings at once, a path deeper than one field (`system.events.<n>`), a
185
+ forced-replacement or forced-deletion operator key, or a read field carrying the wrong type (a
186
+ non-array `events`, a non-boolean `everyone`). Each of those could store a macro reference or a
187
+ trigger set that differs from the one the macro check validated and the approval window showed. An
188
+ `executeMacro` behavior triggers later on player-driven region events, with `everyone: true`
189
+ running the macro on every connected client.
190
+ - `executeScript` behaviors are not supported on any route: Foundry runs their source in every
191
+ connected player's browser with no per-user execution check, which no popup can make reviewable.
129
192
 
130
193
  Installed systems and modules remain trusted Foundry code. They can register behavior types, hooks,
131
194
  ActiveEffect interpretations, or other data-driven features whose effects the bridge cannot classify
@@ -138,11 +201,49 @@ or chat output after the direct command.
138
201
 
139
202
  ## Settings
140
203
 
141
- Setting discovery is read-only. Listing returns registration metadata, while reading a value requires
142
- an explicit namespace and key. Values are serialized with bounded depth, node count, and byte size.
143
-
144
- The CLI does not expose setting writes because settings can alter global security and runtime
145
- behavior and frequently invoke module callbacks.
204
+ Listing returns registration metadata, while reading a value requires an explicit namespace and key,
205
+ singly or in a batch. Values are serialized with bounded depth, node count, and byte size, and this
206
+ module's own secret-bearing settings are redacted from every read.
207
+
208
+ `setting.set` and `setting.set-many` exist because module configuration is a legitimate
209
+ administration task, and they are denied by default because settings can alter global security and
210
+ runtime behavior and frequently invoke module callbacks: a write to `core.permissions` or
211
+ `core.moduleConfiguration` changes what the GM client itself can do or load. Writes go through
212
+ Foundry's own registration and DataField validation, and only registered settings are writable. The
213
+ approval behavior shows the stored value next to the proposed one before a write runs.
214
+
215
+ The command policy stays beyond the CLI's reach by construction: the write commands refuse this
216
+ module's namespace with a structured error in every mode, real, dry-run, and per bulk element
217
+ alike, so no policy setting, credential, or approval timeout can be changed from the surface the
218
+ policy governs. The client-scoped `commandPolicy` and `approvalTimeoutMinutes` settings remain readable
219
+ because they contain no secrets, and only a GM using Foundry changes them: the Command permissions
220
+ window writes the policy, and the main Module Settings form writes the timeout and enforces its
221
+ bounds. Another browser profile or machine has its own values.
222
+
223
+ ## Users
224
+
225
+ User accounts are managed through explicit per-purpose commands rather than one open patch surface.
226
+
227
+ - No command reads or writes `password` or `passwordSalt`. Foundry transmits a set password in
228
+ clear text and hashes it server-side, so a password path through the bridge would expose secrets
229
+ in transcripts and logs; password changes stay in Foundry's own UI.
230
+ - `user.update` edits profile fields and is allowed by default because none of them grant
231
+ authority; the fields are name, color, pronouns, avatar, assigned character, and flags.
232
+ - `user.create` and `user.delete` ask for approval by default. `user.role.set` and
233
+ `user.permissions.set` are denied by default because they change who can do what.
234
+ - A `user.create` carries whatever role it asks for, up to the caller's own, so an approved one can
235
+ mint a gamemaster. That is why it asks for approval rather than running by itself: the approval
236
+ window names the account and the role, including the player role Foundry gives when the command
237
+ asks for none, and the GM reading it is the review point. A GM who does not want that decision
238
+ in the loop at all sets `user.create` to deny.
239
+ - The bridge GM's own account is self-protected: `user.role.set`, `user.permissions.set`, and
240
+ `user.delete` aimed at the user holding the bridge are refused with a structured error, so
241
+ automation cannot demote, remove, or strip the restricted menus from the account it runs through.
242
+ A second GM account carries no such guard; deciding about it is exactly what enabling the command
243
+ means.
244
+ - Foundry's server-side limits stay in force underneath: a role cannot be raised above the caller's
245
+ own, and the last gamemaster account can be neither demoted nor deleted. Those refusals surface
246
+ as permission errors with Foundry's own message.
146
247
 
147
248
  ## Search
148
249
 
@@ -165,8 +266,11 @@ Reads address normalized managed-data paths. Writes are restricted to the active
165
266
  - `packs/` and all descendants.
166
267
 
167
268
  Containment and exclusions are segment-aware and are checked before payload decoding or capability
168
- dispatch. Absolute host paths, traversal, sibling-prefix tricks, and encoded attempts to cross the
169
- boundary are rejected.
269
+ dispatch. Path normalization rejects a segment whose literal or percent-decoded form is `.` or `..`,
270
+ and a segment whose decoded form contains a path separator (so a percent-encoded `/` or `\` cannot
271
+ smuggle traversal inside one segment). Absolute host paths, traversal, sibling-prefix tricks, and
272
+ percent-encoded traversal (`%2e%2e`, `..%2f..`) therefore cannot cross the boundary; this applies to
273
+ every managed-path caller, including `image show`.
170
274
 
171
275
  The module accepts upload content over the authenticated local transport and a managed data-relative
172
276
  destination. It does not read arbitrary files from the operator machine. The CLI may read an explicit
@@ -190,6 +294,9 @@ The daemon and module bound uploads, WebSocket frames, search work, batch sizes,
190
294
  shapes. Oversized operations return structured errors where possible without dropping the shared
191
295
  bridge session.
192
296
 
297
+ The module limits the number and combined size of approval requests. It refuses an excess request
298
+ before display or execution. It does not discard an unread outcome to admit a new request.
299
+
193
300
  The system remains susceptible to ordinary local denial of service by an authorized caller issuing
194
301
  many expensive Foundry operations. It is designed for cooperative local automation, not hostile
195
302
  multi-user scheduling.
@@ -204,7 +311,7 @@ multi-user scheduling.
204
311
  - The label and the client identifier are the only pairing identity fields constrained against terminal
205
312
  escape sequences. World title and GM name arrive on the same unauthenticated pairing request without
206
313
  that constraint, and the CLI prints them as raw text wherever it renders a pending request or a
207
- stored profile both approval prompts and the `auth prune` candidate listing, which precedes an
314
+ stored profile: both approval prompts and the `auth prune` candidate listing, which precedes an
208
315
  irreversible deletion. A local process can therefore redraw the identity lines an operator reads
209
316
  before answering, and a title that survived one approval is re-rendered from the stored record every
210
317
  time a later command lists it.
@@ -219,7 +326,28 @@ multi-user scheduling.
219
326
  - Native Foundry batch operations are not transactional and can partially apply.
220
327
  - Search and read commands can expose GM-visible world content to the local caller.
221
328
  - Large but permitted content can consume browser memory and processing time.
329
+ - An approved command executes when the GM decides, so the world may have changed while it waited.
330
+ Approval does not lock world state.
331
+ - Decisions waiting for a GM live only in that browser session. Reloading the GM client or ending
332
+ its bridge session discards them. Waiting callers receive an indeterminate result.
333
+ - A caller that disappears without a confirmed cancellation leaves its request actionable on the
334
+ GM's screen until the GM decides or the timeout expires. The command envelope carries no client
335
+ identity, so the module cannot tell that the caller is gone.
336
+ - The daemon keeps reservations, approval links, and lost-in-flight idempotency keys in one bounded
337
+ store shared by its clients. It returns `IDEMPOTENCY_STORE_FULL` instead of evicting an
338
+ indeterminate key. Capacity returns when earlier keys settle or expire, or when a daemon restart,
339
+ world switch, or pairing switch clears the store.
340
+ - Command permissions belong to a browser profile. A second paired browser, or the same browser with
341
+ a fresh profile, holds its own permissions, and whichever client holds the bridge is the one whose
342
+ permissions apply.
222
343
  - Declarative content can reference existing executable or module-interpreted content.
344
+ - `image show` accepts an arbitrary off-host `http(s)` URL as its source and pushes it to the
345
+ targeted players' clients unchanged, so an enabled caller can make those browsers fetch and display
346
+ a remote image the operator did not host. Only local sources are confined to the managed data
347
+ boundary; a URL source is a display of external content, not a file read.
348
+ - An enabled `macro.execute` or executable-behavior command makes the GM's enablement and approval
349
+ discipline the effective code-review boundary; an enabled `setting.set` can change core settings
350
+ that alter the GM client's own capabilities or take it down until a reload.
223
351
 
224
352
  ## Operator guidance
225
353
 
@@ -228,6 +356,11 @@ multi-user scheduling.
228
356
  - Use JSON output, dry runs, stable idempotency keys, and post-write reads for automation.
229
357
  - Review every outcome of a bulk operation.
230
358
  - Treat forwarded timeouts and disconnects as potentially committed.
359
+ - Set the approval timeout to the time a GM realistically needs to answer. A long timeout keeps a
360
+ request actionable long after its caller gave up; a short one refuses work the GM would have
361
+ approved. Either way the expiry never executes the command.
362
+ - Configure command permissions in every browser profile that holds the bridge, and review them
363
+ after an update that adds commands.
231
364
  - Review installed systems and modules before authoring automation-sensitive data.
232
365
  - Back up important worlds before large migrations.
233
366
  - Run the live smoke workflow only in a designated test world.
package/docs/skill.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agent skill
2
2
 
3
- fvtt-world-cli ships with `foundry-world-editor`, an installable Agent Skill a short operating
3
+ fvtt-world-cli ships with `foundry-world-editor`, an installable Agent Skill, a short operating
4
4
  manual that teaches an AI agent to drive this CLI safely: how to bring the stack up and check its
5
5
  health, the read → preview → commit → verify loop, how to classify failures before retrying, and
6
6
  where the hard safety boundaries are. Skills follow the open Agent Skills standard, so the same
@@ -18,8 +18,8 @@ fvtt-world-cli skill install
18
18
  ```
19
19
 
20
20
  The default installation delegates to the ecosystem's skills CLI (`npx skills add`), which detects
21
- the agents present on the machine. It keeps one canonical copy under `~/.agents/skills` the
22
- vendor-neutral location of the Agent Skills standard and points each agent's own skill directory
21
+ the agents present on the machine. It keeps one canonical copy under `~/.agents/skills`, the
22
+ vendor-neutral location of the Agent Skills standard, and points each agent's own skill directory
23
23
  at it, so every agent reads the same single copy.
24
24
 
25
25
  An explicit destination works without the skills CLI or network access:
@@ -36,12 +36,12 @@ skill permanently identical to the CLI it came from.
36
36
 
37
37
  The skill is versioned together with the CLI, and the CLI keeps installed copies current on its
38
38
  own: updating the npm package refreshes them, and the daemon performs the same check at startup for
39
- installations updated by other means, such as a git checkout. For updating purposes a copy is one
39
+ installations updated by other means, such as a git checkout. For updates, a copy is one
40
40
  of two kinds:
41
41
 
42
- - An **unmodified** copy exactly what some version of the CLI shipped is replaced silently with
42
+ - An **unmodified** copy, exactly what some version of the CLI shipped, is replaced silently with
43
43
  the current version.
44
- - A **modified** copy one with local edits is never replaced automatically. It produces a
44
+ - A **modified** copy, one with local edits, is never replaced automatically. It produces a
45
45
  warning instead, and keeps producing it until the difference is resolved.
46
46
 
47
47
  The explicit update command follows the same rule and can override it:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fvtt-world-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Safe local CLI and authenticated bridge for automating live Foundry VTT worlds.",
5
5
  "license": "MIT",
6
6
  "author": "DroiD16",
@@ -45,6 +45,6 @@
45
45
  "zod": "^3.24.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@fvtt-world-cli/protocol": "1.0.0"
48
+ "@fvtt-world-cli/protocol": "1.1.0"
49
49
  }
50
50
  }