Skip to content

wyctl: GSettings defaults and token-file safety#321

Merged
justinjoy merged 9 commits into
mainfrom
feature/issue-320-wyctl-gsettings-token-safety
May 14, 2026
Merged

wyctl: GSettings defaults and token-file safety#321
justinjoy merged 9 commits into
mainfrom
feature/issue-320-wyctl-gsettings-token-safety

Conversation

@justinjoy

Copy link
Copy Markdown
Contributor

Summary

Closes #320.

Lands the operator-static defaults and bearer-token hardening requested in the issue. Eight atomic commits, each independently buildable and tested.

  • New GSettings schema org.wyrelog.wyctl covers daemon-url, default-tenant, default-graph, access-token-file, default-timeout-ms, default-guard-loc-class, default-guard-risk, default-guard-timestamp-mode. CLI > GSettings > unset precedence; an empty CLI value is treated as a deliberate operator value, not absence.
  • WYCTL_DISABLE_GSETTINGS=1 env kill switch short-circuits the lookup for CI containers and reproducible runs (literal string 1 only).
  • Token-file safety helper opens with O_NOFOLLOW | O_CLOEXEC | O_RDONLY | O_NOCTTY, fstats the resulting fd, checks S_ISREG, st.st_uid == geteuid(), and (S_IRWXG | S_IRWXO) == 0, then reads bounded to 64 KiB from the same fd. No second path-based syscall. Embedded NULs and oversize files are refused.
  • Typed WyctlTokenFileStatus diagnostic table prints only the path, never the token bytes. The check is the chokepoint every subcommand reaches before any wyl_client_* call.
  • Windows path validates FILE_ATTRIBUTE_REPARSE_POINT (rejected) and FILE_ATTRIBUTE_READONLY (required) via GetFileAttributesW + CreateFileW. Pure classifier wyctl_token_file_classify_windows_attrs(guint32) is compiled and unit-tested on every platform so Linux CI exercises the Windows rejection rules with synthetic inputs.
  • Operator runbook gets the full configuration + permission + diagnostic-catalog section, including the intermediate-path symlink scope statement, the kill-switch strict-match note, and POSIX/Windows setup recipes.

Behavioural notes for reviewers / downstream users

  • Backward-compatible when CLI flags are provided.
  • Backward-incompatible for previously-unsafe token-file setups (mode 0644, foreign owner, terminal symlink) — these now fail with rc=2 and a typed diagnostic. The runbook gives the chmod 0600 + chown remediation.
  • Intermediate-path component symlinks are out of scope (would require Linux-only RESOLVE_BENEATH); the runbook calls this out.
  • Windows ACL validation is reserved (the diagnostic slot exists, no caller emits it yet) and will land in a follow-up.
  • Guard-* GSettings defaults (default-guard-loc-class, default-guard-risk, default-guard-timestamp-mode) are declared in the schema but not yet wired into per-subcommand resolution — they ship as scaffolding for a follow-up that owns the sentinel + enum-mode logic.

Commits in this series

  1. wyctl: install GSettings schema for defaults — schema XML + meson install + build-tree compile + harness test.
  2. wyctl: add CLI/GSettings option resolverwyctl-config.{c,h} + 12 unit tests.
  3. wyctl: resolve status flags via GSettings — exemplar wiring in run_status; 3 subprocess precedence tests.
  4. wyctl: thread option resolver through all commands — fan out to policy/audit/fact/graph/datalog subcommands.
  5. wyctl: reshape resolver fan-out tests for non-status diagnostics — repair commit that fixed and extended the parametrized test contract.
  6. wyctl: harden token-file reads on POSIX — typed safety helper + 14 unit cases + 2 integration cases asserting no HTTP before reject.
  7. wyctl: validate Windows token-file attributes — pure attrs classifier + Win32 API path; 3 cross-platform tests.
  8. docs: document wyctl GSettings and token-file safety — operator runbook section.

Test plan

  • meson test -C builddir wyctl-gschema — 3 keys/types/defaults assertions.
  • meson test -C builddir wyctl-config — 12 resolver semantics tests.
  • meson test -C builddir wyctl-token-file — 17 token-file safety tests (14 POSIX + 3 Windows-attrs).
  • meson test -C builddir wyctl-basic — 35 sub-tests including the 4 new GSettings-supplies-daemon-url cases (status / policy check / audit query / fact put / datalog query) and 2 safety-reject-prevents-HTTP cases.
  • meson test -C builddir wyctl-policy-daemon wyctl-status-daemon client-smoke — pre-existing wyctl integration suite still green.
  • Pre-existing failures (wyrelogd-profiles, wyrelogd-bootstrap-admin, template-tree, engine, valgrind-smoke) are unchanged from main and not introduced by this PR.
  • ./tools/gst-indent is stable on every modified C/H file.
  • No Co-Authored-By, no emoji, no persona/agent identity leaks in any commit message.

Follow-up issues recommended (out of scope for #320)

  • Wire WYCTL_TOKEN_FILE_WINDOWS_ACL_UNAVAILABLE to the actual ACL check.
  • openat2(RESOLVE_BENEATH) for intermediate-path symlink rejection on Linux.
  • Guard-* per-subcommand resolution with the none/now timestamp mode and -1 risk sentinel.
  • Windows operator recipe assumes gsettings CLI; document the registry/keyfile-backend alternative for vanilla Windows installs.

justinjoy added 9 commits May 14, 2026 18:41
Introduces org.wyrelog.wyctl with the eight keys wyctl will resolve from
GSettings when CLI flags are absent: daemon-url, default-tenant,
default-graph, access-token-file, default-timeout-ms,
default-guard-loc-class, default-guard-risk, and
default-guard-timestamp-mode (with a "none"/"now" enum).

The schema installs into ${datadir}/glib-2.0/schemas and the build runs
gnome.post_install(glib_compile_schemas: true) so packaged installations
have a compiled schema cache.

For tests, glib-compile-schemas runs against a staged copy of the schema
in the tests build dir, and a new test-wyctl-gschema unit binary asserts
the schema can be opened, every key has the expected type, no key smells
like a credential value, and the safe-sentinel defaults are intact. The
test runs with GSETTINGS_SCHEMA_DIR pointed at the build dir and
GSETTINGS_BACKEND=memory so it never touches the developer's dconf.

No wyctl behaviour change in this commit: this is the scaffolding the
resolver and token-file safety check will build on.

Tests: meson test -C builddir wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 5/5 OK.
Introduces wyrelog/wyctl/wyctl-config.{c,h} with three functions wyctl
will use to fold GSettings defaults into option parsing without ever
overriding an explicit CLI value:

  wyctl_open_settings()
    g_settings_schema_source_get_default() + schema_source_lookup, so
    a missing schema yields NULL instead of g_error/SIGABRT. Honors the
    WYCTL_DISABLE_GSETTINGS=1 kill switch so a CI container without a
    dconf daemon (or an operator who wants reproducible CLI-only runs)
    can short-circuit before any GSettings call.

  wyctl_resolve_string_option(cli, settings, key)
    Returns an owned string in every non-NULL branch (g_strdup of the
    CLI value, or g_settings_get_string). An empty CLI string is the
    user's deliberate value, NOT absence; an empty GSettings string is
    the "unset" sentinel and surfaces as NULL so the existing missing-
    option diagnostic stays load-bearing.

  wyctl_resolve_uint_option_as_string(cli, settings, key)
    Same shape, but renders the uint with %u so callers can keep
    threading the value through parse_timeout_ms unchanged.

The resolver is not yet wired into wyctl.c; that fan-out lands in the
next commits. This commit ships the unit-testable helper plus 12
tests covering ownership, precedence, the empty-string / unset
distinction, the kill switch, the schema-present happy path, and the
GLib lookup-NULL-on-missing-id invariant the resolver relies on. The
test binary compiles wyctl-config.c directly and runs under the
build-tree's compiled schema with GSETTINGS_BACKEND=memory so it
never touches the developer's dconf.

Tests: meson test -C builddir wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 6/6 OK.
Threads a GSettings handle from main() into WyctlOptions and wires
run_status() through the resolver introduced in the previous commit.
When the operator omits --daemon-url or --timeout-ms the resolver now
falls back to the org.wyrelog.wyctl GSettings values; explicit CLI
flags still win. The resolved values live in g_autofree locals so the
GOptionContext-owned opts.daemon_url / opts.timeout_ms_arg slots are
left alone and there is no ownership conflict on context teardown.

The handle is opened once in main() with g_autoptr (GSettings) and
borrowed into WyctlOptions; it is NULL when the schema is missing or
WYCTL_DISABLE_GSETTINGS=1 is set, and the resolver treats NULL as
"no fallback available" — preserving the existing missing-daemon-URL
diagnostic.

Three subprocess-driven tests cover the end-to-end behaviour by
staging a temporary XDG_CONFIG_HOME with a GSettings keyfile backend
and spawning wyctl with a custom envp:

  * status-gsettings-supplies-daemon-url
      GSettings has the URL, CLI omits --daemon-url, the
      daemon-unavailable diagnostic names the GSettings URL.
  * status-cli-overrides-gsettings
      GSettings has a different URL, CLI supplies the canonical one,
      the diagnostic names the CLI URL and never the GSettings URL.
  * status-kill-switch-disables-gsettings
      GSettings has the URL but WYCTL_DISABLE_GSETTINGS=1 is set, so
      the missing-daemon-URL diagnostic fires instead.

The wyctl-basic meson test invocation now inherits GSETTINGS_SCHEMA_DIR
and GSETTINGS_BACKEND=memory and depends on wyctl-gschemas-compiled
so the child wyctl can find the schema. Existing wyctl-basic cases are
unaffected because empty schema defaults still surface as NULL through
the resolver and trigger the same diagnostics as before.

Tests: meson test -C builddir wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 6/6 OK.
Fans the resolver from run_status out to every other subcommand that
talks to the daemon. After this commit, --daemon-url, --timeout-ms,
--tenant, --graph, and --access-token-file all honour the same
precedence rule (CLI > GSettings > unset) regardless of which wyctl
command is invoked.

Touched call sites:

  * run_policy_decide_request (policy check, policy explain)
  * run_policy_permission_mutation_command (permission-grant/revoke)
  * run_policy_role_mutation_command (role-grant/revoke)
  * run_audit_query
  * create_fact_client + its four callers (run_graph_create,
    run_fact_schema_register, run_fact_put, run_datalog_query)
  * run_policy_decision_command (--access-token-file only)

create_fact_client now takes already-resolved daemon_url,
timeout_ms_arg, tenant, and access_token_file as explicit parameters
so its callers can stage the same locals once and pass them in. The
resolved values live in g_autofree gchar* locals at each call site;
GOptionContext-owned opts.* slots are read but never written back,
preserving the ownership contract established in the run_status
exemplar.

A new helper in tests/test-wyctl-basic.c drives the precedence rule
end-to-end against `policy check` and `audit query` subcommands. Both
new cases stage a temporary XDG_CONFIG_HOME with the GSettings
keyfile backend, spawn wyctl without --daemon-url, and assert that
the daemon-unavailable diagnostic mentions the GSettings URL — the
same assertion shape the run_status tests already use, extended
across the resolver fan-out per the synthesized plan.

Tests: meson test -C builddir wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 6/6 OK.
The previous commit landed two precedence tests (policy check, audit
query) that asserted on the daemon-unavailable diagnostic literal —
but that diagnostic is emitted only by run_status. Every other
subcommand surfaces transport failure as "wyctl: <op> failed" with no
URL echo, so the assertion never matched and the tests failed when
run against a fresh build.

The fix reshapes assert_subcommand_resolves_daemon_url_from_gsettings
into assert_subcommand_consumes_gsettings_daemon_url with a portable
contract: stage a GSettings keyfile carrying the URL, spawn the
subcommand without --daemon-url, and assert that neither
"wyctl: missing daemon URL" nor "wyctl: invalid daemon URL" appears
in stderr. That proves the subcommand made it past URL validation
and the resolver supplied a usable URL — regardless of which failure
diagnostic the subcommand eventually emits.

Also adds the two parametrized cases the synthesis plan calls out
and the previous commit left uncovered: fact put and datalog query.
The runtime contract is identical; only the CLI fixture argv changes.

The kill-switch test for status (preserved from commit 3) plus the
unit-level wyctl-config kill-switch test prove the resolver's kill
switch behaviour. A subcommand-specific kill-switch test would
require each subcommand's options to clear all earlier validation
gates (e.g. a non-empty token file for policy check) so duplicating
that scaffolding adds no incremental signal here.

Tests: meson test -C builddir wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 6/6 OK; new cases policy-check / audit-query / fact-put / datalog-query gsettings-supplies-daemon-url all pass.
Replaces wyctl's previous g_file_get_contents-based token loader with
a typed safety helper that fails closed before any daemon request
escapes. The new code lives in wyrelog/wyctl/wyctl-token-file.{c,h}
and is the single chokepoint every subcommand reaches through the
existing load_access_token_file wrapper.

Safety invariants on POSIX:

  * Single open() with O_NOFOLLOW | O_CLOEXEC | O_RDONLY | O_NOCTTY.
    ELOOP is reported as SYMLINK; ENOENT as NOT_FOUND; everything
    else as IO. A terminal symlink at the path is refused.
  * fstat() on the same fd we opened, so the regular-file / owner /
    mode checks operate on the very inode the read will consume.
    The check uses geteuid() to compare against st.st_uid so a setuid
    wrapper does the right thing.
  * Mode mask (S_IRWXG | S_IRWXO) — any group or other permission
    bit fails with PERMISSIONS_TOO_BROAD. 0600 and stricter accepted.
  * Reads from the same fd we fstatted; no second path-based syscall
    after open. 64 KiB hard cap (WYCTL_TOKEN_FILE_MAX_BYTES). A
    post-read probe rejects files that grew during the read.
  * Embedded NUL bytes are rejected as INVALID_BYTES; an empty file
    is EMPTY. The path is the only thing logged on failure — token
    bytes never appear in a diagnostic.

The classifier (wyctl_token_file_classify_stat) is a pure function
that takes a struct stat + effective uid, so the owner-mismatch and
non-regular paths are unit-testable without root or mkfifo-blocking
quirks.

load_access_token_file in wyctl.c is rewritten to a thin wrapper that
calls wyctl_token_file_read, maps each status to its typed diagnostic
via wyctl_token_file_status_message, and runs the existing
normalize_access_token_file pass on the safety-checked buffer. The
former generic "wyctl: unable to read access token file" diagnostic
is replaced by distinct messages per failure class — distinguishable
diagnostics are an explicit acceptance criterion for the issue.

Tests:

  * tests/test-wyctl-token-file.c — 14 cases covering happy path
    (0600, 0400), each rejection class (0640, 0604, 0660, terminal
    symlink, FIFO via classifier, missing path, missing file, empty,
    embedded NUL, oversize), owner-mismatch via the classifier
    injection seam, and a guard that every status format string
    references only the path, never the token bytes.
  * tests/test-wyctl-basic.c — two integration tests
    (test_policy_check_safety_reject_prevents_http and
    test_audit_query_safety_reject_prevents_http) feed a mode-0640
    token file to wyctl and assert the safety diagnostic fires while
    the subcommand's own "<op> failed" diagnostic is absent —
    proving the rejection happens before any wyl_client_new call.
  * tests/test-wyctl-basic.c diagnostic assertion for a nonexistent
    token path is updated from "wyctl: unable to read access token
    file" to the new "wyctl: access token file not found".

The Windows code path is stubbed to fail closed with
WINDOWS_NOT_READONLY; the actual GetFileAttributesW + reparse-point
+ read-only-attribute check lands in the next commit so this commit
can stay scoped to POSIX hardening.

Tests: meson test -C builddir wyctl-token-file wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 7/7 OK; 14 token-file unit cases plus 2 integration cases pass.
Lights up the Windows token-file safety path that commit 5 stubbed.
The implementation is split into two pieces so the Linux CI runner
can exercise the rejection rules without any Win32 API:

  * wyctl_token_file_classify_windows_attrs(attrs) — a pure helper
    that takes a GetFileAttributesW-style bit mask and applies the
    two safety rules the issue calls out for Windows: reject if the
    FILE_ATTRIBUTE_REPARSE_POINT bit (0x400) is set (symlink /
    junction / mountpoint), reject if FILE_ATTRIBUTE_READONLY
    (0x01) is unset, otherwise accept. Compiled unconditionally so
    the unit tests below can drive it on Linux with synthetic
    inputs.
  * The G_OS_WIN32 branch of wyctl_token_file_read now calls
    GetFileAttributesW, hands the mask to the classifier, and on a
    pass opens the file via CreateFileW(GENERIC_READ, FILE_SHARE_READ,
    OPEN_EXISTING) and reads up to WYCTL_TOKEN_FILE_MAX_BYTES with
    the same overflow probe and embedded-NUL / empty rejections as
    the POSIX path.

ACL validation remains a non-fatal best-effort: the issue text says
"prefer ACL validation if practical." The
WYCTL_TOKEN_FILE_WINDOWS_ACL_UNAVAILABLE status stays in the
diagnostic table as the slot a future ACL-check landing will use; no
caller emits it from this commit. The current Windows path is
fail-closed on the read-only + reparse-point checks alone, which
matches the issue's mandatory minimum.

Three new unit cases cover the pure classifier on every platform:

  * windows-attrs-accept-readonly — readonly bit set yields OK,
    with or without the FILE_ATTRIBUTE_NORMAL bit also set.
  * windows-attrs-reject-not-readonly — plain or zero attributes
    yield WYCTL_TOKEN_FILE_WINDOWS_NOT_READONLY.
  * windows-attrs-reject-reparse-point — reparse-point set
    (with or without read-only also set) yields
    WYCTL_TOKEN_FILE_SYMLINK; the reparse rejection takes
    precedence over the read-only check.

The POSIX branch is untouched; existing 14 unit cases and 2
integration cases still pass.

Tests: meson test -C builddir wyctl-token-file wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 7/7 OK; 17 token-file unit cases pass (14 POSIX + 3 Windows-attrs).
Adds an operator-facing section to docs/operator-runbook.md covering
the surface that issue #320 lands: the GSettings schema for static
wyctl defaults, the CLI > GSettings > unset precedence rule, the
WYCTL_DISABLE_GSETTINGS=1 kill switch, the POSIX and Windows
token-file permission requirements, the intermediate-path symlink
scope statement, the complete diagnostic-message catalog, and
end-to-end setup recipes for POSIX and Windows operators.

The runbook now satisfies the issue's "Operator documentation
explains the configuration and token-file permission requirements"
acceptance criterion. Key points operators need to act on:

  * Schema id is org.wyrelog.wyctl; manual installs must run
    glib-compile-schemas to refresh the gschemas.compiled cache.
  * Each schema key's purpose, type, and the empty-string-is-unset
    sentinel convention is tabulated.
  * The kill switch matches the literal string "1" only — operators
    who type "true"/"yes"/"on" will silently get GSettings still
    active.
  * POSIX safety: regular file owned by geteuid(), mode mask
    (S_IRWXG | S_IRWXO) must be zero, terminal symlinks refused via
    O_NOFOLLOW. Intermediate-path symlinks are out of scope for
    the GA hardening pass and operators are warned to keep every
    parent directory operator-owned.
  * Windows safety: FILE_ATTRIBUTE_READONLY must be set;
    FILE_ATTRIBUTE_REPARSE_POINT must not be set. ACL validation
    is reserved for follow-up and does not fire today.
  * Diagnostic catalog enumerates every greppable stderr substring
    and confirms exit-code 2 with no daemon request on rejection.
  * Setup recipes use install + redirect to keep the token out of
    shell history and out of environment variables once configured.

Tests: meson test -C builddir wyctl-token-file wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke — 7/7 OK (docs-only commit; no source changes).
CI surfaced three classes of failures that did not show up locally
because the developer workstation umask is 0, masking the issues
that strict-umask runners see immediately.

1. `g_file_set_contents` umask interaction. Every test that creates
   a temp token file via `g_file_open_tmp` then writes contents with
   `g_file_set_contents` ends up with a 0644 file on a 0022 umask
   runner — the atomic rename inside set_contents applies umask to
   the new file, masking the 0600 that mkstemp originally created.
   The wyctl token-file safety check then rejects the test fixture
   as "permissions too broad" and the test bails out before it can
   exercise the path it is actually testing.

   Fix: after every `g_file_set_contents` call on a token path in
   `tests/test-wyctl-basic.c` (15 sites) and the
   `write_token_file` helper in `tests/test-wyctl-policy-daemon.c`,
   explicitly `g_chmod (path, 0600)`. The bulk fix in
   test-wyctl-basic.c was applied by a regex sweep matching the
   pattern `g_assert_true (g_file_set_contents (<var>, ...))`
   followed by `g_assert_no_error (error)` so every site gets the
   same chmod-down treatment.

   Shell-side fix: the bootstrap-admin and datalog-product-flow
   harnesses (`tests/check-wyrelogd-bootstrap-admin.sh`,
   `tests/check-wyrelogd-datalog-product-flow.sh`) write tokens via
   Python `open(...).write(token)` which also honours umask. Adds
   `os.chmod(token_path, 0o600)` inside each heredoc.

2. macOS `O_NOFOLLOW` undeclared under strict `c_std=c17`. Apple
   SDKs hide POSIX-only BSD features behind `_DARWIN_C_SOURCE` when
   the compiler predefines `__STRICT_ANSI__` (which clang does
   under `-std=c17`). Setting `_POSIX_C_SOURCE` alone is not enough.
   Matches the existing pattern in
   `tests/test-policy-store-toctou.c`.

3. Windows compile failed because `wyctl-token-file.c` referenced
   `DWORD`, `HANDLE`, `ReadFile`, etc. inside `#ifdef G_OS_WIN32`
   without including `<windows.h>`. Adds the include in the Win32
   branch.

Tests: `bash -c "umask 022 && meson test -C builddir wyctl-token-file wyctl-config wyctl-gschema wyctl-basic wyctl-policy-daemon wyctl-status-daemon client-smoke --print-errorlogs"` — 7/7 OK under the strict umask that previously broke CI.
@justinjoy justinjoy merged commit 905815b into main May 14, 2026
3 checks passed
@justinjoy justinjoy deleted the feature/issue-320-wyctl-gsettings-token-safety branch May 14, 2026 11:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add GSettings defaults and token-file safety checks to wyctl

1 participant