Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .agents/skills/jaws/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,24 @@ The handler candidate is asked via `JawsClick` / `JawsContextMenu` / `JawsInput`
- Generic jq paths require JavaScript array-index names at Go arrays and slices:
`"0"` or a nonzero decimal without a leading zero, at most `4294967294` and
representable as Go `int`. Other components produce an error matching
`jq.ErrPathNotFound` at an array or slice. String-keyed map entries and JSON
field names are exact; empty components are ignored.
`jq.ErrPathNotFound` at an array or slice. String-keyed map entries are exact;
empty components are ignored.
- Struct path components and map-to-struct keys follow `encoding/json`'s default
field-selection rules. An exact `json:"-"` tag excludes an otherwise selected
exported field. For a non-promoting field, a valid nonempty tag name is used
verbatim, while an absent, empty, or invalid name falls back to the Go field
name; `json:"-,"` names the field `-`. Ambiguous fields are absent.
- An anonymous struct without a valid explicit JSON name contributes promoted
fields directly without a Go-type-name component: use `value`, not
`Inner.value`, or add an explicit tag for a nested path. Promotion reaches
exported fields through unexported embedded structs. An explicitly named
unexported anonymous struct is not itself a readable or writable endpoint or
writable map-to-struct key, but longer paths can reach its exported fields. A
`ClientCheck` using `jq.Get` must inspect a longer path or the tentative Go
value.
- Reads and generic writes that traverse a nil pointer fail with
`jq.ErrPathNotFound`; generic writes do not allocate it. `JsVar.JawsGetPath`
returns nil on lookup failure, indistinguishable from a resolved nil value.
- Browser JSON numbers use JavaScript `Number` values. Integers outside
`-9007199254740991` through `9007199254740991` may round, and a browser write
may commit the rounded value to Go.
Expand Down
39 changes: 29 additions & 10 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,22 +263,41 @@ access. It does not scan values for that name; own `"__proto__"` members remain
data. The selected Go setter (`jq.Set`, `jq.SetChecked`, or an application
`PathSetter`) independently controls accepted server-side paths.

**Trust boundary (application responsibility):** the generic JSON path will set
*any* exported field matched by its `json` tag, or by its Go name when the tag
has no explicit name, and will append to a slice one element per `Set` message
(`json:"-"` fields remain unwritable). The 32 KiB WebSocket read limit bounds one
message, not the accumulated server state, and there is (see I6) no per-message
rate limit. With
a nil `JsVar.ClientCheck`, a type-correct generic write has no additional
state-size policy.
**Trust boundary (application responsibility):** the generic JSON path can set
every exported field selected by `encoding/json`'s default field rules and can
append one slice element per `Set` message. Struct path components and
map-to-struct keys use a valid nonempty JSON tag name verbatim for non-promoting
fields; an absent, empty, or invalid name falls back to the Go field name. An
exact `json:"-"` tag excludes an otherwise selected exported field, while
`json:"-,"` names that field `-`. Ambiguous fields are absent from the path
namespace.

An anonymous struct without a valid explicit JSON name contributes its promoted
fields directly without a Go-type-name component: use `value`, not
`Inner.value`, or tag the anonymous field to create a nested path. Promotion
reaches exported fields through unexported embedded structs. An explicitly
named unexported anonymous struct is not itself a readable or writable endpoint
or writable map-to-struct key, but longer paths can reach its exported fields.
Reads and generic writes that traverse a nil pointer fail with
`jq.ErrPathNotFound`; writes do not allocate it, and `JsVar.JawsGetPath` returns
nil, which cannot be distinguished from a successfully resolved nil value.

The 32 KiB WebSocket read limit bounds one message, not accumulated server state,
and there is (see I6) no per-message rate limit. With a nil
`JsVar.ClientCheck`, a type-correct generic write has no additional state-size
policy.

Applications can set `JsVar.ClientCheck` to inspect the complete tentative
value and the browser-supplied jq path before a generic browser write commits.
The path is passed through unchanged. Empty components are ignored, so
`.value.` aliases `value`, and both `""` and `"."` address the root. Generic
array and slice writes require canonical JavaScript array-index names
representable as Go `int`; string-keyed map entries and JSON field names are
exact. Invalid array or slice paths are rejected before `ClientCheck` runs.
representable as Go `int`, and string-keyed map entries are exact. Struct paths
and map-to-struct keys follow the selection rules above. Invalid writes do not
reach `ClientCheck`; ignored map-to-struct keys do not by themselves cause a
check. A check using `jq.Get` cannot inspect an explicitly named unexported
anonymous struct at its own endpoint after a tentative write beneath it; inspect
a longer exported-field path or the Go value directly.
Treat the raw path as an inspection hint, not an authorization key; use
`ui.PathSetter` to allow-list paths. A returned error
rolls the write back without broadcasting it. If the error matches
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/coder/websocket v1.8.15
github.com/linkdata/deadlock v0.5.5
github.com/linkdata/jq v0.3.0
github.com/linkdata/jq v0.5.0
github.com/linkdata/secureheaders v1.5.0
github.com/linkdata/staticserve v1.1.8
golang.org/x/net v0.58.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo=
github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0=
github.com/linkdata/jq v0.3.0 h1:GKDcydShVH63mLUvZJwoEvSFO9HS7M2iYxp7HOEsEfw=
github.com/linkdata/jq v0.3.0/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE=
github.com/linkdata/jq v0.5.0 h1:JtTlQKBsH4VlKde2md0iNp69ebXedBTY6C9aNXo0LeY=
github.com/linkdata/jq v0.5.0/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE=
github.com/linkdata/secureheaders v1.5.0 h1:4kI290hrVUYqluxF8pQfRYpbZuorSd9+8GlMgrLcrK4=
github.com/linkdata/secureheaders v1.5.0/go.mod h1:50TiKmPaWki8gIZXmBxrLUinyJst12YuN3EbsXZMO+4=
github.com/linkdata/staticserve v1.1.8 h1:qpm0dAaAsik1YrETSiMoS7ciBL9qPVNUFcfdio/ol84=
Expand Down
23 changes: 20 additions & 3 deletions lib/ui/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,32 @@ value before an actual generic write commits. It receives the browser-supplied
jq path unchanged. Empty components are ignored, so `.value.` aliases `value`;
treat the raw path as an inspection hint, not an authorization key. Array and
slice components must be canonical JavaScript array-index names representable
as Go `int`; string-keyed map entries and JSON field names are exact. Use
`PathSetter` to allow-list paths and operations.
as Go `int`, and string-keyed map entries are exact. Struct path components and
map-to-struct keys follow `encoding/json`'s default field-selection rules. An
exact `json:"-"` tag excludes an otherwise selected exported field. For a
non-promoting field, a valid nonempty tag name is used verbatim, while an absent,
empty, or invalid name falls back to the Go field name; `json:"-,"` therefore
names the field `-`. Ambiguous fields are absent from the path namespace.

An anonymous struct without a valid explicit JSON name contributes its promoted
fields directly without a Go-type-name component: use `value`, not
`Inner.value`, or tag the anonymous field to create a nested path. Promotion
reaches exported fields through unexported embedded structs. An explicitly
named unexported anonymous struct is not itself a readable or writable endpoint
or writable map-to-struct key, but longer paths can reach its exported fields.
Reads and generic writes that traverse a nil pointer fail with
`jq.ErrPathNotFound`, and generic writes do not allocate it. `JawsGetPath`
returns nil on lookup failure, so nil does not distinguish failure from a
resolved nil value. Use `PathSetter` to allow-list paths and operations.

A check runs while the application locker is held. It must inspect only: do not
mutate or retain tentative state, re-enter the JsVar, call a path setter, acquire
the same locker, or return/wrap `jaws.ErrEventUnhandled`. A nil result commits;
an error rolls back without a broadcast. The browser already changed locally,
so an ordinary rejection can leave it divergent until application
resynchronization.
resynchronization. A check using `jq.Get` cannot inspect an explicitly named
unexported anonymous struct at its own endpoint after a tentative write beneath
it; inspect a longer exported-field path or the Go value directly.

The check sees tentative Go state, not necessarily the decoded value later used
for a peer broadcast. jq conversions and ignored map-to-struct fields can make
Expand Down
37 changes: 29 additions & 8 deletions lib/ui/jsvar.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,26 @@ func JSONSizeCheck[T any](maxBytes int) (check JsVarCheck[T]) {
// indices representable as int: "0" or ASCII decimal digits without a leading
// zero, at most 4294967294. A component that violates these rules produces an
// error matching [github.com/linkdata/jq.ErrPathNotFound] when traversal reaches
// an array or slice. String-keyed map entries and JSON field names are matched
// exactly. Empty components are ignored, and both "" and "." address the root.
// an array or slice. String-keyed map entries are matched exactly. Struct path
// components and map-to-struct keys follow the default field-selection rules of
// [encoding/json]. An exact json:"-" tag excludes an otherwise selected exported
// field. For a non-promoting field, a valid nonempty JSON tag name is used
// verbatim, while an absent, empty, or invalid name falls back to the Go field
// name; json:"-," therefore names the field "-". Ambiguous fields are absent
// from the path namespace.
//
// An anonymous struct without a valid explicit JSON name contributes its
// promoted fields directly and does not add its Go type name as a component:
// use "value", not "Inner.value", or give the anonymous field an explicit tag
// to create a nested path. Promotion reaches exported fields through unexported
// embedded structs. An explicitly named unexported anonymous struct is not
// itself a readable or writable endpoint or writable map-to-struct key, but
// longer paths can reach its exported fields.
//
// Reads and generic writes that traverse a nil pointer produce an error matching
// [github.com/linkdata/jq.ErrPathNotFound], and generic writes do not allocate
// the pointer. Empty components are ignored, and both "" and "." address the
// root.
//
// While the WebSocket is open, jawsVar sends one complete message per matching
// live binding, subject to [jaws.Request.ServeHTTP]'s inbound limit.
Expand Down Expand Up @@ -258,11 +276,9 @@ func JSONSizeCheck[T any](maxBytes int) (check JsVarCheck[T]) {
//
// SECURITY: a JsVar is client-writable. Incoming browser "set" messages are
// applied by path to the bound value. If the bound value implements [PathSetter],
// its JawsSetPath validates and applies the change. Otherwise the generic path
// setter ([github.com/linkdata/jq.Set]) can set any exported field — matched by its
// json tag, or by the Go field name when its json tag has no explicit name (a
// json:"-" tag is never writable) — and append to slices one element per
// message.
// its JawsSetPath validates and applies the change. Otherwise
// [github.com/linkdata/jq.Set] can write any exported field addressable by the
// generic path rules above and append one element per message to a slice.
//
// There is no default cumulative size bound. Set [JsVar.ClientCheck] before first
// use to validate each tentative generic browser update. [JSONSizeCheck] provides
Expand All @@ -273,6 +289,9 @@ func JSONSizeCheck[T any](maxBytes int) (check JsVarCheck[T]) {
// A ClientCheck does not run for rendering, programmatic writes, invalid or
// unchanged writes, or values implementing PathSetter. It is an acceptance gate,
// not a monitor that proves the current value always satisfies an invariant.
// A check that uses [github.com/linkdata/jq.Get] cannot inspect an explicitly
// named unexported anonymous struct at its own endpoint; it must inspect a
// longer exported-field path or the tentative Go value directly.
//
// A size check does not prevent a client from setting individual exported fields.
// When only some fields or paths should be client-writable, implement [PathSetter]
Expand All @@ -296,7 +315,9 @@ type JsVar[T any] struct {
// JawsGetPath returns the value at jsPath.
//
// A path containing only empty components returns the same logical root value
// as [JsVar.JawsGet]. Lookup errors are logged on elem when possible.
// as [JsVar.JawsGet]. Lookup errors return nil and are logged on elem when
// possible. A nil result therefore does not distinguish a lookup failure from a
// successfully resolved nil value.
func (jsvar *JsVar[T]) JawsGetPath(elem *jaws.Element, jsPath string) (value any) {
if strings.Trim(jsPath, ".") == "" {
return jsvar.JawsGet(elem)
Expand Down
Loading
Loading