44 KiB
ThothII Authentication Important-Finding Remediation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Close the three remaining Important authentication findings on feat/thoth-auth: POSIX local-registry ownership, retained-capability restore staging, and handle-relative Windows claim removal.
Architecture: POSIX registry reads bind every path and descriptor metadata observation to one validated effective UID. Restore staging creates one random private regular file directly below a retained safeio.PrivateDirectoryHandle, keeps that directory capability for the entire stream and cleanup lifecycle, and never authorizes cleanup through a pathname. Windows canonical claim removal reuses the already handle-relative PrivateDirectoryHandle.RemoveClaim implementation instead of reopening and deleting absolute paths.
Tech Stack: Node.js 24.16.0, TypeScript, Fastify auth services, Vitest, Go 1.26.5, golang.org/x/sys, native Windows GitHub Actions, Docker Compose release smokes.
Spec: docs/superpowers/specs/2026-08-16-thothii-authentication-design.md
Review basis: .superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md and the Task 15 section of PROJECT_STATE.md at finding baseline 178113a.
Global Constraints
- The only host CLI is
tht; do not add another executable or revivethothctl. - Production modes are
localandoidc;noneandmockare development/test only, whileupstreamremains a deprecated migration adapter. - Authentik is the first-release certified OIDC provider; the browser OIDC protocol layer must not contain Authentik-specific login logic.
- The ID token must contain direct claim
groups: string[]; absent, malformed, indirect, or overage claims fail closed. - Only configured groups are checked and mapped. Unmapped provider groups are ignored silently and never produce a warning.
- Every configured group must be proven to exist through the configured group-catalog adapter; first release provides
authentik. - Local passwords use Argon2id v19 with
m=65536,t=3,p=1, 16-byte random salt, and 32-byte output. - A remembered local session has a seven-day idle timeout and thirty-day absolute timeout and survives browser/backend restarts.
- No raw session cookie, password, CSRF token, OIDC token, client secret, Authentik API token, or password hash may enter logs or diagnostic output.
- Browser authentication uses an opaque
HttpOnly,SameSite=Lax, path-scoped cookie;Secureis conditional on an HTTPS public URL so loopback HTTP remains functional. - Every cookie-authenticated state-changing route requires a CSRF token and same-origin browser checks.
- Authentication configuration is installation-global, but static checks appear in workspace validation and live checks appear in workspace connection tests.
- Workspace document content remains in its workspace language; application chrome and new authentication UI strings are English.
- JSON CLI stdout is pristine. Prompts, progress, and human guidance go to stderr.
- Node is exactly
24.16.0; Docker usessha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7. - Existing session artifacts, workflow persistence, workspace ownership, and Pi RPC behavior must not change.
- Preserve unrelated user changes and the existing untracked
.playwright-cli/and.thothctl/paths.
Execution Contract
Run the tasks in numerical order. Tasks 2 and 3 both touch safeio; they must not be parallelized.
| Task | Implementer | Reasoning | Mandatory reviewer |
|---|---|---|---|
| 1 | fresh Terra | high | fresh Terra, ultra |
| 2 | fresh Terra | ultra | fresh Terra, ultra |
| 3 | fresh Terra | ultra | fresh Terra, ultra |
| 4 | fresh Terra | max | fresh Terra, ultra, whole-branch scope |
For every task:
- Dispatch a new Terra implementer that has not worked on any earlier authentication task.
- Give it only this plan, the design spec,
global-constraints.md, the Task 15 report, and the current task's relevant files. - Require RED-GREEN TDD, focused tests before broad tests, and one task-scoped commit.
- Dispatch a different, fresh Terra reviewer after the commit. The reviewer is read-only and checks the task diff, tests, security invariants, and scope.
- Do not start the next numbered task unless the reviewer reports
CLEANfor Critical/Important issues. A requested fix gets a new Terra implementer and, after its commit, another fresh Terra reviewer. - Three review/fix cycles are the hard breaker for one task. If the third review is not
CLEAN, stop and record the exact blocker; do not weaken an invariant or recast the finding as accepted risk.
The review of Task 4 is also the final whole-branch review over 178113a..HEAD. A native-Windows test that was only cross-compiled is PENDING, never PASS.
Scope Boundaries and Design Choices
- Do not change login, role, session, CSRF, OIDC, group-mapping, or CLI behavior except where a test proves an unintended dependency on one of the three fixes.
- Do not introduce a second filesystem abstraction. Extend
safeio.PrivateDirectoryHandlewith one streaming-file method and reuse its existing handle-relativeRemoveRegularandRemoveClaimoperations. - Do not create a temporary staging subdirectory. A unique archive file directly under the retained
restore-stagingdirectory removes an unnecessary cleanup capability and still allows candidate and recovery archives to coexist. - Do not test POSIX ownership by requiring root or calling
chown. Vitest must override returned metadata while the production code continues to use the realnode:fsAPI. - Do not treat the known Ruff, MkDocs, installation-wording, Pi model-policy, deployment-coupling, L2, PSD, provider-readiness, or Windows-Docker statuses as fixed by this plan. Re-run and report them honestly where the release matrix requires them.
- Do not put credentials, internal endpoints, provider identities, registry names, raw paths containing secrets, or unsanitized Docker output into retained evidence.
Target File Structure
Backend ownership boundary
backend/src/auth/local-registry.ts— validates one effective UID across every POSIXlstatandfstatobservation.backend/test/local-registry.test.ts— injects foreign UID metadata at path-file, descriptor-file, path-directory, and descriptor-directory boundaries.
Retained restore staging
tools/tht/internal/safeio/private_root.go— adds the cross-platform streaming regular-file interface.tools/tht/internal/safeio/private_root_unix.go— creates an exclusive owner-private stream withopenatunder the retained directory descriptor.tools/tht/internal/safeio/private_root_windows.go— creates an exclusive owner-private stream with NTRootDirectory-relative access.tools/tht/internal/safeio/files_test.go— proves streaming creation and handle-relative removal through the public directory capability.tools/tht/internal/backup/preflight.go— owns the retained staging directory capability untilstagedArchive.Closefinishes.tools/tht/internal/backup/preflight_test.go— updates lifecycle, failure-cleanup, and same-filesystem assertions for direct-child staging.tools/tht/internal/backup/preflight_unix_test.go— proves cleanup targets the pinned Unix directory after a lexical ancestor swap.tools/tht/internal/backup/preflight_windows_test.go— proves native Windows blocks the swap while the no-delete directory handle is retained..github/workflows/deployment.yml— executes the security tests natively in the existingwindows-clonejob.
Windows claim removal
tools/tht/internal/safeio/claim_windows.go— delegates canonical removal to a retainedPrivateDirectoryHandle.tools/tht/internal/safeio/claim_windows_test.go— proves native Windows ancestor replacement cannot redirect deletion.
Certification evidence
.artifacts/task-15/automated-gates.json— sanitized machine-readable results bound to the final source commit..artifacts/task-15/unified-docker-images.json— exact final-smoke image identities without registry names..superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md— remediation and release-gate narrative.PROJECT_STATE.md— current status, exact source SHA, review verdict, PASS/FAIL/PENDING matrix.
Task 1: Require Effective-UID Ownership for the POSIX Local Registry
Files:
- Modify:
backend/src/auth/local-registry.ts:53-193,229-250 - Modify:
backend/test/local-registry.test.ts:1-190
Interfaces:
-
Produces:
runtimeOwner(): number, which returns a non-negative safe integer fromprocess.geteuid()or throwslocal_user_registry_invalid. -
Produces:
fileMetadata(info: Stats, owner: number): FileIdentityanddirectoryMetadata(info: Stats, owner: number): DirectoryIdentity. -
Produces:
registryIdentity(path: string, owner: number),readBounded(path: string, owner: number), andload(path: string, owner: number). -
Preserves:
createLocalUserRegistry(usersPath, options)and every publicLocalUserRegistrymethod. -
Step 1: Add a controllable metadata wrapper to the existing Vitest file
Place a hoisted state object before the imports from local-registry.ts, mock only node:fs metadata calls, and leave all real filesystem mutation/open/read calls intact:
type OwnershipObservation =
| "file-lstat"
| "file-fstat"
| "directory-lstat"
| "directory-fstat";
const ownershipOverride = vi.hoisted(() => ({
observation: undefined as OwnershipObservation | undefined,
uid: undefined as number | undefined,
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const replaceUid = <T extends object>(value: T, uid: number): T => new Proxy(value, {
get(target, property) {
if (property === "uid") return uid;
const member = Reflect.get(target, property, target);
return typeof member === "function" ? member.bind(target) : member;
},
});
const maybeReplace = <T extends import("node:fs").Stats>(
value: T,
source: "lstat" | "fstat",
): T => {
const kind = value.isDirectory() ? "directory" : "file";
return ownershipOverride.observation === `${kind}-${source}` && ownershipOverride.uid !== undefined
? replaceUid(value, ownershipOverride.uid)
: value;
};
return {
...actual,
lstatSync(path: import("node:fs").PathLike) {
return maybeReplace(actual.lstatSync(path), "lstat");
},
fstatSync(fd: number) {
return maybeReplace(actual.fstatSync(fd), "fstat");
},
};
});
Reset both fields in the existing afterEach before deleting fixture files so one case cannot contaminate the next.
- Step 2: Add four foreign-owner rejection cases
Add a POSIX-only table test after the existing unsafe-metadata test:
test.runIf(process.platform !== "win32").each([
"file-lstat",
"file-fstat",
"directory-lstat",
"directory-fstat",
] as const)("rejects foreign ownership at the %s boundary", async (observation) => {
const fixture = writeRegistry(registryYaml(userYaml()));
const owner = process.geteuid();
ownershipOverride.observation = observation;
ownershipOverride.uid = owner === 0 ? 1 : owner - 1;
await expectInvalid(
createLocalUserRegistry(fixture.path).findByUsername("admin"),
["admin", passwordHash, fixture.path],
);
});
Add the invalid-effective-UID case explicitly:
test.runIf(process.platform !== "win32")("fails closed when the effective UID is invalid", async () => {
const fixture = writeRegistry(registryYaml(userYaml()));
const getuid = vi.spyOn(process, "geteuid").mockReturnValue(-1);
try {
await expectInvalid(
createLocalUserRegistry(fixture.path).findByUsername("admin"),
["admin", passwordHash, fixture.path],
);
} finally {
getuid.mockRestore();
}
});
Keep the existing Windows bridge test green to prove native Windows does not enter the POSIX owner path.
- Step 3: Run the focused tests and verify RED
cd backend
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx vitest run test/local-registry.test.ts -t "foreign ownership|effective UID"
Expected: all new POSIX ownership cases fail because uid is not checked; the Windows-only bridge case remains excluded or passing according to host platform.
- Step 4: Add one fail-closed effective-UID resolver
Use the same invariant already present in backend/src/auth/config.ts, but keep this patch local to the registry to avoid unrelated config refactoring:
function runtimeOwner(): number {
if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid();
const owner = process.geteuid();
if (!Number.isSafeInteger(owner) || owner < 0) throw invalid();
return owner;
}
Add uid: number to both FileIdentity and DirectoryIdentity; include it in sameFileIdentity and sameDirectoryIdentity.
- Step 5: Thread one owner through every POSIX observation
Change the metadata helpers so every path and descriptor stat uses the same owner captured at the start of currentPosix():
function fileMetadata(info: Stats, owner: number): FileIdentity {
if (!info.isFile() || info.uid !== owner || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) {
throw invalid();
}
if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid();
return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs };
}
function directoryMetadata(info: Stats, owner: number): DirectoryIdentity {
if (!info.isDirectory() || info.uid !== owner || (info.mode & 0o7777) !== 0o700) throw invalid();
return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777 };
}
directoryIdentity, registryIdentity, readBounded, and load must all require owner. currentPosix captures it once and passes it through both cache probes and both load attempts:
function currentPosix(): LocalUserRecord[] {
try {
const owner = runtimeOwner();
const before = registryIdentity(usersPath, owner);
if (cached && sameIdentity(cached.identity, before)) return cached.records;
for (let attempt = 0; attempt < 2; attempt += 1) {
const loaded = load(usersPath, owner);
if (sameIdentity(loaded.identity, registryIdentity(usersPath, owner))) {
cached = loaded;
return loaded.records;
}
}
} catch {
throw invalid();
}
throw invalid();
}
Do not call runtimeOwner() inside individual metadata helpers: changing the expected owner between observations would weaken the snapshot invariant.
- Step 6: Run focused and complete Node 24 gates
cd backend
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx vitest run test/local-registry.test.ts
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx tsc --noEmit -p .
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx vitest run
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npm run build
Expected: focused and full backend suites pass; no error contains a user name, hash, or registry path.
- Step 7: Commit the ownership remediation
git add backend/src/auth/local-registry.ts backend/test/local-registry.test.ts
git commit -m "fix(auth): require local registry ownership"
Mandatory Terra review gate: Review the task commit against the finding. Confirm all eight POSIX metadata observations in readBounded plus the file-and-directory observations in both cache probes flow through owner-checking helpers, invalid/missing geteuid fails closed, and the Windows bridge remains unchanged. Verdict must be CLEAN before Task 2.
Task 2: Retain the Restore-Staging Capability Through Stream and Cleanup
Files:
- Modify:
tools/tht/internal/safeio/private_root.go:8-23 - Modify:
tools/tht/internal/safeio/private_root_unix.go:162-202 - Modify:
tools/tht/internal/safeio/private_root_windows.go:512-539 - Modify:
tools/tht/internal/safeio/files_test.go - Modify:
tools/tht/internal/backup/preflight.go:97-103,261-374 - Modify:
tools/tht/internal/backup/preflight_test.go:60-101,403-488 - Modify:
tools/tht/internal/backup/preflight_unix_test.go - Modify:
tools/tht/internal/backup/preflight_windows_test.go - Modify:
.github/workflows/deployment.yml:125-153
Interfaces:
-
Produces:
PrivateDirectoryHandle.CreateRegularFile(name string) (*os.File, bool, error). -
Contract:
created=false, file=nil, err=nilmeans a safe existing leaf prevented exclusive creation; any unsafe existing leaf returnsErrUnsafeFile. -
Contract: a successful caller owns the returned
*os.File; the directory handle remains the sole cleanup authority throughRemoveRegular(name). -
Produces:
newStagingArchiveName() (string, error)returningarchive-<32 lowercase hex>.zipfrom 16 cryptographically random bytes. -
Produces:
stagedArchive{file *os.File, parent safeio.PrivateDirectoryHandle, name string, path string};pathis diagnostic only and is never passed to a remove operation. -
Step 1: Add a failing cross-platform streaming-capability test
In tools/tht/internal/safeio/files_test.go, open a private directory, call the new method, stream bytes, rewind/read them, and remove the leaf through the same directory capability:
func TestPrivateDirectoryCreatesAndRemovesStreamingRegularFile(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
t.Fatal(err)
}
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-stream-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
if err := ProtectPrivateDirectory(root); err != nil {
t.Fatal(err)
}
directory, found, err := OpenPrivateDirectory(root, true)
if err != nil || !found {
t.Fatalf("OpenPrivateDirectory() = found %v, err %v", found, err)
}
defer directory.Close()
file, created, err := directory.CreateRegularFile("archive-stream.zip")
if err != nil || !created || file == nil {
t.Fatalf("CreateRegularFile() = file %v, created %v, err %v", file, created, err)
}
if _, err := file.Write([]byte("private archive")); err != nil {
t.Fatal(err)
}
if err := file.Sync(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
removed, err := directory.RemoveRegular("archive-stream.zip")
if err != nil || !removed {
t.Fatalf("RemoveRegular() = removed %v, err %v", removed, err)
}
}
Add an adjacent case that pre-creates a safe 0600 leaf and expects (nil, false, nil), then replaces a different leaf with a symlink/reparse point and expects ErrUnsafeFile:
if created, err := directory.CreateRegular("existing.zip", []byte("existing")); err != nil || !created {
t.Fatalf("CreateRegular(existing.zip) = created %v, err %v", created, err)
}
if file, created, err := directory.CreateRegularFile("existing.zip"); err != nil || created || file != nil {
t.Fatalf("CreateRegularFile(existing.zip) = file %v, created %v, err %v", file, created, err)
}
if created, err := directory.CreateRegular("target.zip", []byte("target")); err != nil || !created {
t.Fatalf("CreateRegular(target.zip) = created %v, err %v", created, err)
}
testsupport.SymlinkOrSkip(t, filepath.Join(root, "target.zip"), filepath.Join(root, "linked.zip"))
if file, created, err := directory.CreateRegularFile("linked.zip"); !errors.Is(err, ErrUnsafeFile) || created || file != nil {
t.Fatalf("CreateRegularFile(linked.zip) = file %v, created %v, err %v", file, created, err)
}
- Step 2: Add failing StageArchive cleanup-race tests
Add TestStageArchiveCloseUsesPinnedRootAfterAncestorSwap in both platform files. Install the existing test hook before staging and react only to before-stage-archive-remove.
The Unix case must:
- complete
StageArchivefirst; - rename
result.stagingRoottoresult.stagingRoot + "-original"inside the close hook; - replace the lexical root with a symlink to an owner-private outside directory containing
sentinel; - call
staged.Close(); - prove the random archive is absent below the moved original root and the outside sentinel is unchanged.
The Windows case must attempt the same root rename in the close hook and require the rename to fail while the retained no-delete directory handle is live. It then requires staged.Close() to succeed, the archive to be absent, and an outside sentinel to remain unchanged.
Use these hook bodies so the race point is deterministic and occurs after streaming, immediately before removal:
// Unix hook.
restoreHook := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
if stage != "before-stage-archive-remove" || swapped {
return
}
swapped = true
if err := os.Rename(result.stagingRoot, movedRoot); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, result.stagingRoot); err != nil {
t.Fatal(err)
}
})
defer restoreHook()
// Windows hook.
restoreHook := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
if stage != "before-stage-archive-remove" || attemptedSwap {
return
}
attemptedSwap = true
if err := os.Rename(result.stagingRoot, result.stagingRoot+"-moved"); err == nil {
t.Fatal("staging-root rename succeeded while Close retained its directory handle")
}
})
defer restoreHook()
After staged.Close, require the hook boolean to be true. The Unix test checks os.Stat(filepath.Join(movedRoot, stagedName)) returns os.ErrNotExist; the Windows test checks the original root contains no archive leaf. Both read the outside sentinel and compare its exact original bytes.
Update existing assertions that reference staged.directory: direct-child staging means filepath.Dir(staged.path) == result.stagingRoot in the normal case.
- Step 3: Run focused tests and verify RED
cd tools/tht
go test ./internal/safeio ./internal/backup -run 'TestPrivateDirectoryCreatesAndRemovesStreamingRegularFile|TestStageArchiveCloseUsesPinnedRootAfterAncestorSwap' -count=1
Expected: compile failure because CreateRegularFile, parent, and name do not exist, or the old pathname cleanup is redirected by the Unix swap.
- Step 4: Add the minimal streaming method to
PrivateDirectoryHandle
Import os in private_root.go and add exactly one method:
type PrivateDirectoryHandle interface {
Close() error
Validate() error
OpenChild(name string, ensure bool) (PrivateDirectoryHandle, bool, error)
CreateRegular(name string, contents []byte) (bool, error)
CreateRegularFile(name string) (*os.File, bool, error)
ReadRegular(name string, maximum int64) ([]byte, bool, error)
ReplaceRegular(name string, contents []byte) error
RemoveRegular(name string) (bool, error)
ListPage(maximumEntries int, afterName string, validName func(string) bool, validLinks func(string, uint64) bool) (PrivateDirectoryPage, error)
ClaimRegular(source, claim string) (bool, error)
ReadClaim(source, claim string, maximum int64) ([]byte, bool, error)
RemoveClaim(source, claim string) (bool, error)
}
Do not add CreateTemporaryDirectory, RemoveDirectory, or pathname-returning cleanup methods.
- Step 5: Implement exclusive streaming creation on Unix
Use unix.Openat(directory.descriptor, name, O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC|O_NOFOLLOW, 0600). On success:
- wrap the descriptor in
os.NewFile; - apply
Fchmod(0600); - require
privateUnixRootRegular(stat, 1)afterFstat; - require
directory.Validate()andFsync(directory.descriptor)before return; - on every failure, close the descriptor and
Unlinkat(directory.descriptor, name, 0).
On EEXIST, inspect the existing leaf with requirePrivateUnixRootRegularAt(..., 1) and return (nil, false, nil) only for a safe existing regular file. Every other condition returns ErrUnsafeFile.
- Step 6: Implement exclusive streaming creation on Windows
Reuse createWindowsPrivateRegularAt(directory.handle, name) so the owner-only DACL is installed in the same NT relative create. Before detaching the handle:
- require a one-link regular non-reparse file;
- require
directory.Validate(); - convert the retained file handle with
os.NewFileand clear the wrapper handle to prevent double close; - on failure call
closeAndDeleteWindowsPrivateRegularwhile the parent handle is still retained.
If relative open proves that a safe one-link leaf already exists, return (nil, false, nil); unsafe or ambiguous failures return ErrUnsafeFile.
- Step 7: Replace StageArchive pathname ownership with the retained directory capability
Add the bounded, cryptographically random leaf-name helper:
func newStagingArchiveName() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return "archive-" + hex.EncodeToString(value) + ".zip", nil
}
After the capacity check, open the staging root once:
parent, found, err := safeio.OpenPrivateDirectory(result.stagingRoot, true)
if err != nil || !found {
return nil, errors.New("open private restore staging root")
}
Generate up to eight 16-byte random names. For each name, call parent.CreateRegularFile(name); retry only the safe collision result. If no file is created, close the parent and return a sanitized creation error.
Construct:
staged := &stagedArchive{
file: file,
parent: parent,
name: name,
path: filepath.Join(result.stagingRoot, name),
}
Keep the existing source revalidation, bounded streaming, digest, Sync, and rewind logic unchanged. Remove os.MkdirTemp, ProtectPrivateDirectory, the nested archive.zip, and every os.Remove cleanup path from StageArchive and stagedArchive.Close.
- Step 8: Make Close exhaustive, capability-relative, and idempotent
Close must perform all cleanup attempts in this order even if an earlier operation fails:
- close
staged.fileand set it tonil; - call
safeio.NotifyPrivateDirectoryTestHookForTest("before-stage-archive-remove"); - call
staged.parent.RemoveRegular(staged.name)and requireremoved=trueon the first close; - close
staged.parentand set it tonil; - clear
nameandpath; - return only
destroy private restore staging archiveif any operation failed.
A second Close() returns nil. No error may contain the staging path or archive bytes.
- Step 9: Add the native Windows test gate
In the existing windows-clone job, after Go setup and before the clone-contract script, add:
- name: Run native Windows retained-capability tests
working-directory: tools/tht
run: go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1
This job is the native execution authority. A Linux/macOS cross-compile only proves buildability.
- Step 10: Run focused, broad, race, and cross-compile gates
cd tools/tht
gofmt -w internal/safeio/private_root.go internal/safeio/private_root_unix.go \
internal/safeio/private_root_windows.go internal/safeio/files_test.go \
internal/backup/preflight.go internal/backup/preflight_test.go \
internal/backup/preflight_unix_test.go internal/backup/preflight_windows_test.go
go test ./internal/safeio ./internal/backup -count=1
go test -race ./internal/safeio ./internal/backup -count=1
go vet ./internal/safeio ./internal/backup
GOOS=windows GOARCH=amd64 go test -c ./internal/safeio -o /tmp/tht-safeio-windows.test.exe
GOOS=windows GOARCH=amd64 go test -c ./internal/backup -o /tmp/tht-backup-windows.test.exe
Expected: all host tests pass and both Windows test executables compile. Record native execution as pending until the workflow job actually runs.
- Step 11: Commit the retained-staging remediation
git add tools/tht/internal/safeio/private_root.go \
tools/tht/internal/safeio/private_root_unix.go \
tools/tht/internal/safeio/private_root_windows.go \
tools/tht/internal/safeio/files_test.go \
tools/tht/internal/backup/preflight.go \
tools/tht/internal/backup/preflight_test.go \
tools/tht/internal/backup/preflight_unix_test.go \
tools/tht/internal/backup/preflight_windows_test.go \
.github/workflows/deployment.yml
git commit -m "fix(backup): retain staging cleanup capability"
Mandatory Terra review gate: Confirm no StageArchive creation or cleanup decision uses os.MkdirTemp, os.Remove, or a re-resolved staging pathname; path is diagnostic only; failure cleanup uses the retained parent; Unix swap cleanup removes only the moved-root archive; Windows native coverage is wired into CI; all handles close on every exit. Verdict must be CLEAN before Task 3.
Task 3: Remove Windows Claims Through the Retained Parent Handle
Files:
- Modify:
tools/tht/internal/safeio/claim_windows.go:126-160 - Create:
tools/tht/internal/safeio/claim_windows_test.go
Interfaces:
-
Consumes:
OpenPrivateDirectory(path string, ensure bool) (PrivateDirectoryHandle, bool, error). -
Consumes:
PrivateDirectoryHandle.RemoveClaim(source, claim string) (bool, error). -
Preserves:
RemoveCanonicalPrivateClaim(source, claim string) (bool, error)and its absent/orphan semantics. -
Produces test hook stage:
after-canonical-private-claim-parent-open. -
Step 1: Add a native-Windows ancestor-swap test
Create a //go:build windows test in package safeio. It must:
- create and protect one private directory;
- create
state.jsonas an owner-private regular file; - create
state.claimthroughClaimCanonicalPrivateRegularso both names refer to the verified two-link file; - create a separate protected outside directory with a sentinel that must survive;
- install
SetPrivateDirectoryTestHookForTestand react toafter-canonical-private-claim-parent-open; - attempt to rename the protected source directory and require Windows to reject the rename while the retained no-delete handle is live;
- call
RemoveCanonicalPrivateClaimand require(true, nil); - prove both original names are absent and every outside file is unchanged.
Use a private-file helper and the following main test shape:
func createWindowsPrivateTestFile(t *testing.T, path string, contents []byte) {
t.Helper()
file, err := CreateCanonicalNewPrivateFile(path)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write(contents); err != nil {
_ = file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
}
func TestRemoveCanonicalPrivateClaimRetainsParentDuringDeletion(t *testing.T) {
parent := filepath.Join(t.TempDir(), "claims")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
if err := ProtectPrivateDirectory(parent); err != nil {
t.Fatal(err)
}
source := filepath.Join(parent, "state.json")
claim := filepath.Join(parent, "state.claim")
createWindowsPrivateTestFile(t, source, []byte("state"))
if claimed, err := ClaimCanonicalPrivateRegular(source, claim); err != nil || !claimed {
t.Fatalf("ClaimCanonicalPrivateRegular() = claimed %v, err %v", claimed, err)
}
outside := filepath.Join(t.TempDir(), "outside")
if err := os.Mkdir(outside, 0o700); err != nil {
t.Fatal(err)
}
if err := ProtectPrivateDirectory(outside); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(outside, "sentinel")
createWindowsPrivateTestFile(t, sentinel, []byte("outside-safe"))
attemptedSwap := false
restoreHook := SetPrivateDirectoryTestHookForTest(func(stage string) {
if stage != "after-canonical-private-claim-parent-open" || attemptedSwap {
return
}
attemptedSwap = true
if err := os.Rename(parent, parent+"-moved"); err == nil {
t.Fatal("claim parent rename succeeded while removal retained its handle")
}
})
defer restoreHook()
removed, err := RemoveCanonicalPrivateClaim(source, claim)
if err != nil || !removed || !attemptedSwap {
t.Fatalf("RemoveCanonicalPrivateClaim() = removed %v, attempted %v, err %v", removed, attemptedSwap, err)
}
for _, path := range []string{source, claim} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("removed path %q still exists: %v", filepath.Base(path), err)
}
}
if contents, err := os.ReadFile(sentinel); err != nil || string(contents) != "outside-safe" {
t.Fatalf("outside sentinel = %q, err %v", contents, err)
}
}
Add adjacent cases for an orphan one-link claim (false, nil, orphan preserved) and a mismatched two-file pair (ErrUnsafeFile, neither file removed). Build the mismatch as two independent private files, each with its own auxiliary hard link, so both source and claim have link count two but different file identities. These cases lock in current recovery semantics while changing deletion authority.
- Step 2: Cross-compile the new test and verify RED behavior by inspection
cd tools/tht
GOOS=windows GOARCH=amd64 go test -c ./internal/safeio -o /tmp/tht-safeio-claim-red.test.exe
Expected: compilation succeeds against the current API, but the new hook is never observed and the test would fail natively because the current function closes retained handles before absolute-path DeleteFile calls. Do not label this compile-only result as an executed RED test.
- Step 3: Replace absolute-path deletion with one retained directory operation
Implement the Windows function with a named return so a parent-close failure can fail closed:
func removeCanonicalPrivateClaim(source, claim string) (removed bool, resultErr error) {
parentPath := filepath.Dir(source)
sourceName := filepath.Base(source)
claimName := filepath.Base(claim)
if parentPath != filepath.Dir(claim) || !validPrivateLeafName(sourceName) || !validPrivateLeafName(claimName) {
return false, ErrUnsafeFile
}
directory, found, err := OpenPrivateDirectory(parentPath, false)
if err != nil || !found {
return false, ErrUnsafeFile
}
defer func() {
if closeErr := directory.Close(); closeErr != nil && resultErr == nil {
resultErr = ErrUnsafeFile
}
}()
NotifyPrivateDirectoryTestHookForTest("after-canonical-private-claim-parent-open")
return directory.RemoveClaim(sourceName, claimName)
}
Delete the old openWindowsPrivateRegular/close/windows.DeleteFile sequence from this function. Do not duplicate link-count, identity, orphan, or delete-on-close logic: windowsPrivateDirectory.RemoveClaim already implements those checks relative to directory.handle.
- Step 4: Run Windows compilation plus host-wide Go gates
cd tools/tht
gofmt -w internal/safeio/claim_windows.go internal/safeio/claim_windows_test.go
GOOS=windows GOARCH=amd64 go test -c ./internal/safeio -o /tmp/tht-safeio-claim-windows.test.exe
go test ./internal/safeio ./internal/authstorage -count=1
go test -race ./...
go vet ./...
go build ./cmd/tht
Expected: cross-compile, safeio/authstorage semantics, race suite, vet, and CLI build pass. Native ancestor-swap execution remains pending until Task 4 runs the Windows workflow.
- Step 5: Commit the Windows claim remediation
git add tools/tht/internal/safeio/claim_windows.go \
tools/tht/internal/safeio/claim_windows_test.go
git commit -m "fix(auth): remove Windows claims by retained handle"
Mandatory Terra review gate: Confirm removeCanonicalPrivateClaim contains no DeleteFile and performs no deletion after closing the parent capability; RemoveClaim remains the single identity/link/orphan authority; the test attempts an ancestor replacement and protects outside sentinels; Unix behavior is untouched. Verdict must be CLEAN before Task 4.
Task 4: Re-Certify the Remediation and Refresh Sanitized Evidence
Files:
- Modify:
.artifacts/task-15/automated-gates.json - Modify:
.artifacts/task-15/unified-docker-images.jsononly after a new immutable-source Docker smoke - Modify:
.superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md - Modify:
PROJECT_STATE.md
Interfaces:
-
Consumes every test and workflow gate from Tasks 1-3.
-
Produces one source-bound PASS/FAIL/PENDING matrix with hashes for retained artifacts.
-
Preserves the historical Task 15 evidence as provenance; new results supersede rather than rewrite historical source SHAs.
-
Step 1: Freeze and record the source under test
After Tasks 1-3 and their reviews are clean:
git status --short
AUTH_REMEDIATION_SOURCE="$(git rev-parse HEAD)"
printf '%s\n' "$AUTH_REMEDIATION_SOURCE"
Only .playwright-cli/ and .thothctl/ may be untracked. Record the resulting commit as AUTH_REMEDIATION_SOURCE in the operator notes. If any tracked source changes after this point, discard downstream certification results and restart this task from Step 1.
- Step 2: Run all Go security and build gates
cd tools/tht
go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1
go test -race ./...
go vet ./...
go build ./cmd/tht
GOOS=windows GOARCH=amd64 go test -c ./internal/safeio -o /tmp/tht-safeio-final-windows.test.exe
GOOS=windows GOARCH=amd64 go test -c ./internal/backup -o /tmp/tht-backup-final-windows.test.exe
GOOS=windows GOARCH=amd64 go test -c ./internal/authstorage -o /tmp/tht-authstorage-final-windows.test.exe
GOOS=windows GOARCH=amd64 go build -o /tmp/tht-final-windows.exe ./cmd/tht
Record package counts and exact failures. Cross-compilation is a separate PASS row and never substitutes for native Windows execution.
- Step 3: Run the exact Node 24 backend and frontend gates
cd backend
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH node --version
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx tsc --noEmit -p .
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx vitest run
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npm run build
cd ../frontend
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx tsc -b
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npx vitest run
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npm run build
PATH=/Users/mp/.nvm/versions/node/v24.16.0/bin:$PATH npm run e2e -- --grep "authentication|F1"
The first command must print v24.16.0. Run the existing sentinel leak scan in scripts/authentication-smoke.sh; retain no browser credential or token.
- Step 4: Run harness and static/documentation gates without laundering baselines
cd ../harness
.venv/bin/pytest -q
.venv/bin/ruff check .
cd ..
bash scripts/auth-docs-smoke.sh
Record current counts. Existing baseline failures remain FAIL unless the exact command is now green; this remediation task does not authorize unrelated fixes.
- Step 5: Execute the native Windows authority
Push or dispatch only if the execution session has explicit repository authorization. The remote branch must contain AUTH_REMEDIATION_SOURCE:
AUTH_REMEDIATION_SOURCE="$(git rev-parse HEAD)"
git push origin HEAD:feat/thoth-auth
gh workflow run deployment.yml --ref feat/thoth-auth -f windows_docker_startup=false
AUTH_WINDOWS_RUN_ID=""
for AUTH_WINDOWS_LOOKUP_ATTEMPT in {1..12}; do
AUTH_WINDOWS_RUN_ID="$(gh run list --workflow deployment.yml --branch feat/thoth-auth \
--event workflow_dispatch --limit 10 --json databaseId,headSha \
--jq "map(select(.headSha == \"$AUTH_REMEDIATION_SOURCE\"))[0].databaseId // empty")"
if [[ -n "$AUTH_WINDOWS_RUN_ID" ]]; then break; fi
sleep 5
done
test -n "$AUTH_WINDOWS_RUN_ID"
gh run watch "$AUTH_WINDOWS_RUN_ID" --exit-status
gh run view "$AUTH_WINDOWS_RUN_ID" --json jobs \
--jq '.jobs[] | select(.name == "Windows clone and Compose contract") | {name,conclusion,url}'
Wait for the matching source SHA and require the Windows clone and Compose contract job, including Run native Windows retained-capability tests, to pass. Retain the run URL/ID and the two focused test names, not raw runner logs. If dispatch or a native runner is unavailable, record PENDING: native Windows execution unavailable and do not mark the three-finding remediation complete.
- Step 6: Run shell, Compose, authentication, and final Docker gates
Run lightweight contracts first:
bash -n scripts/*.sh
bash scripts/authentication-smoke.sh
bash scripts/auth-docs-smoke.sh
docker compose -f compose.yaml config --quiet
docker compose -f compose.yaml -f compose.unified.yaml config --quiet
Then run the unified Docker smoke exactly once against the frozen source:
timeout --signal=TERM --kill-after=45s 32m bash scripts/unified-deployment-smoke.sh
Require task-scoped cleanup and five-image source traceability. A failed Docker smoke stays FAIL; do not rerun it against changed source without restarting at Step 1.
- Step 7: Run optional external gates only when their prerequisites exist
cd harness
.venv/bin/pytest -q -m l2
Follow docs/testing/authentication-manual-acceptance.md for real PSD/Authentik acceptance only when real identity/access is available. Missing L2 secrets, Authentik access, PSD identities, or a provider port are PENDING with the exact prerequisite category; they are not remediation failures and are not PASS.
- Step 8: Refresh machine-readable and narrative evidence
Update .artifacts/task-15/automated-gates.json with:
source_commitequal toAUTH_REMEDIATION_SOURCE;- UTC start/end timestamps;
- exact Node, Go, and Pi versions;
- focused ownership, StageArchive, and Windows claim test status;
- native Windows run ID/status distinct from cross-compile status;
- backend/frontend/harness counts;
- Docker run ID and cleanup status;
- unchanged known FAIL/PENDING rows where still applicable.
Regenerate .artifacts/task-15/unified-docker-images.json only from the new Docker run and retain digests without registry names. Compute both SHA-256 values and place them in the Task 15 report.
Update PROJECT_STATE.md so its leading Task 15 section states separately:
-
whether all three Important findings are closed by a clean final review;
-
whether native Windows execution passed;
-
whether authentication is implementation-complete;
-
whether release acceptance remains blocked by unrelated FAIL/PENDING gates.
-
Step 9: Commit only sanitized certification evidence
git add .artifacts/task-15/automated-gates.json \
.artifacts/task-15/unified-docker-images.json \
.superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md \
PROJECT_STATE.md
git commit -m "docs(auth): record remediation certification"
Before committing, search the staged diff for fixture secrets, tokens, internal endpoints, user identities, and registry names. The report may contain hashes, versions, test counts, job IDs, and sanitized failure categories only.
Mandatory final Terra review gate: Review 178113a..HEAD, not only the evidence commit. Reproduce focused tests, inspect every affected security boundary, verify native-Windows evidence is executed rather than inferred, and issue separate verdicts for (a) the three Important findings and (b) overall release readiness. The remediation is complete only when verdict (a) is CLEAN; overall release readiness must remain PENDING or FAIL wherever unrelated gates still require it.
Final Acceptance Checklist
- Every POSIX
lstat/fstatmetadata path forusers.yamland its parent requires the same valid effective UID. - Foreign ownership at file-path, file-descriptor, directory-path, and directory-descriptor observations fails with only
local_user_registry_invalid. StageArchiveretains onePrivateDirectoryHandlefrom file creation throughClosecleanup.StageArchivehas no pathname-authorized file or directory removal and no nested temporary directory.- Unix ancestor replacement cannot redirect staging cleanup; native Windows blocks replacement while the retained handle is live.
- Windows canonical claim removal delegates to
PrivateDirectoryHandle.RemoveClaimand contains no post-closeDeleteFilecall. - Native Windows executes both retained-capability race tests; cross-compilation is recorded separately.
- A fresh Terra reviewer reports no Critical/Important finding after every task.
- A fresh final Terra reviewer reports the three original Important findings
CLEANover178113a..HEAD. - Evidence is bound to one immutable source SHA, sanitized, hashed, and honest about all remaining FAIL/PENDING release gates.