Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d111802
docs: add versioned agent-client capability matrix and policy controls
roodboi Oct 12, 2026
58c485a
fix(mcp): harden run argv and tool input contract
roodboi Oct 12, 2026
c6c0db3
fix(env): harden legacy env key files and AES-GCM envelope parsing
roodboi Oct 12, 2026
de862d7
Merge remote-tracking branch 'origin/next' into w14-rework
roodboi Oct 12, 2026
dc88c6b
Merge remote-tracking branch 'origin/next' into w06-rework
roodboi Oct 12, 2026
ffeecce
fix(agents): generate capability matrix doc and parse all guidance co…
roodboi Oct 12, 2026
56bac3a
fix(mcp): make typed refusals value-free and keep unknown-tool in-band
roodboi Oct 12, 2026
12e80af
chore: merge origin/next into wf/w15-w5-6
roodboi Oct 12, 2026
cd76b26
test(env): pin AES-GCM IV, size and tag-length guards with discrimina…
roodboi Oct 12, 2026
bcf88ec
fix(init): stop writing repo-local agent files by default
roodboi Oct 12, 2026
c005262
fix(env): resolve --env base and default the same way on every command
roodboi Oct 12, 2026
6136946
fix(agents): count corrupted and unreadable integrations as drift
roodboi Oct 12, 2026
9854d1b
test(env): pin the console logger backend for CLI refusal tests
roodboi Oct 12, 2026
6e1ff63
test(mcp): assert closed ambiguous_project refusal in project-name suite
roodboi Oct 12, 2026
1f8b51e
chore(x01): temporary witness cost trace for hosted measurement
roodboi Oct 12, 2026
0911e6d
perf(storage): coalesce witness helper work per native command
roodboi Oct 12, 2026
eb9a678
chore(x01): revert temporary witness cost trace
roodboi Oct 12, 2026
7b2ccfc
test(e2e): restore the networks scenario budget after witness coalescing
roodboi Oct 12, 2026
fa7741c
Merge remote-tracking branch 'origin/next' into wf/x03-witness-probes
roodboi Oct 12, 2026
37f093a
perf(storage): make witness engine and executable fences cheaper
roodboi Oct 12, 2026
6a4e118
chore(x03): temporary witness probe trace for hosted measurement
roodboi Oct 12, 2026
2b11ed3
fix(storage): keep closed probe cancellation for direct engine-ID fences
roodboi Oct 12, 2026
a777bc4
test(storage): pin witness fence counts and refusal controls
roodboi Oct 12, 2026
994ddb8
chore(x03): revert temporary witness probe trace
roodboi Oct 12, 2026
3edbd54
feat(agents): package offline skill references and the HACK-P pattern…
roodboi Oct 12, 2026
726cc27
test(e2e): add agent-surface-freeze negative control for guidance writes
roodboi Oct 12, 2026
9b971fa
ci: add gated Bun crash experiment and read-only crash census
roodboi Oct 12, 2026
1342871
perf(storage): read engine identity directly for docker context selec…
roodboi Oct 12, 2026
c2ecf9c
chore(x04): temporary witness probe trace for hosted measurement
roodboi Oct 12, 2026
5cf9f0f
chore(x04): revert temporary witness probe trace
roodboi Oct 12, 2026
ebe0539
test(agents): cover reference generation and reference-style and HTML…
roodboi Oct 12, 2026
6b54980
test: keep the Bun crash loop tests free of real crash reports
roodboi Oct 12, 2026
7a05b84
Merge branch 'next' into wf/w15-w5-6
roodboi Oct 12, 2026
c00468c
Merge branch 'next' into wf/w06-w5-3
roodboi Oct 12, 2026
e9b1252
Merge branch 'next' into wf/w13-w5b-2
roodboi Oct 12, 2026
af786b5
Merge branch 'next' into wf/w10-w5b-0
roodboi Oct 12, 2026
9db4dba
Merge branch 'next' into wf/w14-w5-5
roodboi Oct 12, 2026
5a731cc
Merge remote-tracking branch 'origin/wf/w06-w5-3' into train/queue-pr…
roodboi Oct 12, 2026
38d9d7f
Merge remote-tracking branch 'origin/wf/w14-w5-5' into train/queue-pr…
roodboi Oct 12, 2026
d50e935
Merge remote-tracking branch 'origin/wf/w15-w5-6' into train/queue-pr…
roodboi Oct 12, 2026
8bab674
Merge remote-tracking branch 'origin/wf/w10-w5b-0' into train/queue-p…
roodboi Oct 12, 2026
26958c9
Merge remote-tracking branch 'origin/wf/w13-w5b-2' into train/queue-p…
roodboi Oct 12, 2026
07a1834
Merge remote-tracking branch 'origin/wf/w07-w6-3' into train/queue-pr…
roodboi Oct 12, 2026
a92e3ab
Merge remote-tracking branch 'origin/wf/w02-w6-1' into train/queue-pr…
roodboi Oct 12, 2026
3e261d5
Merge remote-tracking branch 'origin/wf/w05-w6-2' into train/queue-pr…
roodboi Oct 12, 2026
9f20366
fix(env): write .hack/.env and the encrypted store atomically with 0600
roodboi Oct 12, 2026
10b6d73
fix(agents): point the env matrix bullet at the offline env guide
roodboi Oct 12, 2026
77da332
Merge remote-tracking branch 'origin/wf/w13-w5b-2' into train/queue-p…
roodboi Oct 12, 2026
cb665d5
fix(env): scope the env-file symlink refusal doc to write paths
roodboi Oct 12, 2026
c2b41ff
chore: merge train/queue-preview into wf/y3-envfile-b6
roodboi Oct 12, 2026
5f8cb79
fix(logger): pass messages to gum after an end-of-options marker
roodboi Oct 12, 2026
6a043c0
Merge branch 'next' into wf/c6-gum-dash-b7
roodboi Oct 12, 2026
4243d60
Merge branch 'next' into wf/y3-envfile-b6
roodboi Oct 12, 2026
989fcea
Merge branch 'wf/c6-gum-dash-b7' into wf/y3-envfile-b6
roodboi Oct 12, 2026
483a616
Merge branch 'next' into wf/y3-envfile-b6
roodboi Oct 12, 2026
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
19 changes: 18 additions & 1 deletion docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ That means `.hack/.env` is no longer the primary runtime source of truth.

`hack env materialize` is manual by design. Use it only when you need a compatibility file for an external tool that expects `.env` on disk.

Hack writes `.hack/.env` and `.hack/.env.<overlay>` (from `materialize`, legacy
`hack env set`, and the daemon env route) with mode `0600` by atomic replace: a
private temporary file in `.hack/` is written, fsynced and renamed over the target,
so readers never see a partial file. An existing file with group or other access is
tightened to `0600` even when its contents are unchanged. These write and update paths
refuse, with a fixed message that names no file or value, an env file that is a symlink
(including a dangling one) or not a regular file, or one whose `.hack` directory is a
symlink. Replace the link with a regular file or directory to continue. Code paths
that only read an env file do not apply this check and still follow a symlink.

For host execution, `hack host exec`, `hack host shell`, and lifecycle hooks/processes use a host-local view.
That means Hack prefers host-usable values when it can:

Expand Down Expand Up @@ -529,7 +539,14 @@ The modern YAML env model is the canonical repo format today.

Some older surfaces are still compatibility-oriented:

- `hack env backend ...` remains relevant for legacy repos and older secret-store configurations
- `hack env backend ...` remains relevant for legacy repos and older secret-store configurations.
The `encrypted_file` store (also used by the `cloud` shim) is replaced atomically
with mode `0600`, and set/delete hold an in-process queue plus a cooperative
`<store>.lock` so concurrent writers, including separate processes, do not lose
updates. A store path that is a symlink is refused for writes. The v1 store
envelope and legacy `v1:` values authenticate no associated data: they are not
bound to a path, project or variable name beyond the scoped key inside the
encrypted store plaintext
- the daemon's env HTTP route (used by the macOS app) reads and writes the legacy dotenv-shaped
contract (`source: 'plain_env'|'keychain'`, `resolvedFrom: 'dotenv'/'process'/'keychain'/'portable_backend'`),
not the modern YAML `hack env list --json` shape; there is no web dashboard surface anymore
Expand Down
24 changes: 17 additions & 7 deletions src/lib/hack-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import {
} from "../constants.ts";
import { readControlPlaneConfig } from "../control-plane/sdk/config.ts";
import { parseDotEnv, serializeDotEnv } from "./env.ts";
import { readTextFile, writeTextFileIfChanged } from "./fs.ts";
import { readTextFile } from "./fs.ts";
import { getString, isRecord } from "./guards.ts";
import {
readPrivateEnvFile,
writePrivateEnvFileIfChanged,
} from "./private-env-file.ts";
import { readProjectDefaultEnvConfig } from "./project.ts";
import type { SecretStoreDescriptor } from "./secret-store.ts";
import {
Expand Down Expand Up @@ -502,19 +506,23 @@ export async function upsertDotEnvValue(opts: {
readonly key: string;
readonly value: string;
}): Promise<{ readonly changed: boolean }> {
const existingText = (await readTextFile(opts.envFile)) ?? "";
const existingText =
(await readPrivateEnvFile({ envFile: opts.envFile })) ?? "";
const env = parseDotEnv(existingText);
const nextEnv: Record<string, string> = { ...env, [opts.key]: opts.value };
const nextText = serializeDotEnvStable(nextEnv);
const result = await writeTextFileIfChanged(opts.envFile, nextText);
return { changed: result.changed };
return await writePrivateEnvFileIfChanged({
envFile: opts.envFile,
text: nextText,
});
}

export async function removeDotEnvKey(opts: {
readonly envFile: string;
readonly key: string;
}): Promise<{ readonly changed: boolean }> {
const existingText = (await readTextFile(opts.envFile)) ?? "";
const existingText =
(await readPrivateEnvFile({ envFile: opts.envFile })) ?? "";
const env = parseDotEnv(existingText);
if (!(opts.key in env)) {
return { changed: false };
Expand All @@ -527,8 +535,10 @@ export async function removeDotEnvKey(opts: {
nextEnv[key] = value;
}
const nextText = serializeDotEnvStable(nextEnv);
const result = await writeTextFileIfChanged(opts.envFile, nextText);
return { changed: result.changed };
return await writePrivateEnvFileIfChanged({
envFile: opts.envFile,
text: nextText,
});
}

export function resolveKeychainServiceName(opts: {
Expand Down
74 changes: 74 additions & 0 deletions src/lib/private-env-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { lstat, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { isRecord } from "./guards.ts";
import {
type ReplaceablePrivateFileOptions,
readReplaceablePrivateFile,
replacePrivateFileAtomic,
} from "./private-file.ts";

/**
* Plaintext env files under `.hack/` (`.hack/.env` and `.hack/.env.<overlay>`).
*
* Ownership: Hack owns these files. They are read without following a symlink
* and replaced atomically with mode 0600. A symlink or non-regular entry at the
* file, or a symlinked `.hack` directory, is refused with
* {@link PRIVATE_ENV_FILE_REFUSAL}; the refusal never names the file, overlay
* or any value. Ancestors above `.hack` are not inspected.
*/
export const PRIVATE_ENV_FILE_REFUSAL =
"Refused .hack env file access: the env file or its .hack directory is a symlink or not a regular file. Replace it with a regular file or directory and retry; values omitted.";

function privateEnvFileRefusal(): Error {
return new Error(PRIVATE_ENV_FILE_REFUSAL);
}

function envFileOptions(envFile: string): ReplaceablePrivateFileOptions {
return {
path: envFile,
directories: [dirname(envFile)],
refusal: privateEnvFileRefusal,
};
}

/** Current text, or null when the env file is absent. */
export async function readPrivateEnvFile(opts: {
readonly envFile: string;
}): Promise<string | null> {
const current = await readReplaceablePrivateFile(
envFileOptions(opts.envFile)
);
return current?.text ?? null;
}

/**
* Writes `text` when it differs from the current contents, or when the existing
* file grants any group/other permission. `changed` reports a content change.
*/
export async function writePrivateEnvFileIfChanged(opts: {
readonly envFile: string;
readonly text: string;
}): Promise<{ readonly changed: boolean }> {
await ensureEnvFileDirectory({ directory: dirname(opts.envFile) });
const options = envFileOptions(opts.envFile);
const existing = await readReplaceablePrivateFile(options);
const changed = existing?.text !== opts.text;
if (changed || ((existing?.mode ?? 0) & 0o077) !== 0) {
await replacePrivateFileAtomic({ ...options, text: opts.text });
}
return { changed };
}

/** Creates a missing `.hack` directory; an existing entry is left to the refusal checks. */
async function ensureEnvFileDirectory(opts: {
readonly directory: string;
}): Promise<void> {
try {
await lstat(opts.directory);
} catch (error: unknown) {
if (!(isRecord(error) && error.code === "ENOENT")) {
throw error;
}
await mkdir(opts.directory, { recursive: true });
}
}
166 changes: 165 additions & 1 deletion src/lib/private-file.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import {
type FileHandle,
lstat,
open,
realpath,
rename,
unlink,
} from "node:fs/promises";
import { dirname } from "node:path";
import { basename, dirname, join } from "node:path";
import { isRecord } from "./guards.ts";

/**
Expand Down Expand Up @@ -199,6 +201,168 @@ export async function createPrivateFileExclusive(opts: {
await syncDirectory({ path: dirname(opts.path) });
}

/** Options shared by the no-follow read and atomic replace of a private file. */
export type ReplaceablePrivateFileOptions = {
readonly path: string;
/**
* Directories (normally the file's parent inside the project) that must be
* real directories rather than symlinks, with a stable identity across the
* operation. Ancestors outside this list are not inspected.
*/
readonly directories: readonly string[];
/** Builds the caller's fixed, value-free refusal. */
readonly refusal: () => Error;
};

/** Current contents and permission bits of a replaceable private file. */
export type ReplaceablePrivateFile = {
readonly text: string;
readonly mode: number;
};

type DirectoryIdentity = { readonly path: string; readonly stats: Stats };

async function captureRealDirectories(
opts: ReplaceablePrivateFileOptions
): Promise<readonly DirectoryIdentity[]> {
return await Promise.all(
opts.directories.map(async (path) => {
const stats = await lstat(path).catch(() => null);
if (!stats?.isDirectory()) {
throw opts.refusal();
}
return { path, stats };
})
);
}

async function recheckRealDirectories(opts: {
readonly directories: readonly DirectoryIdentity[];
readonly refusal: () => Error;
}): Promise<void> {
for (const { path, stats } of opts.directories) {
const current = await lstat(path).catch(() => null);
if (
!current?.isDirectory() ||
current.dev !== stats.dev ||
current.ino !== stats.ino
) {
throw opts.refusal();
}
}
}

/** Null when absent; refuses a symlink (dangling or not) or non-regular entry. */
async function observeReplaceableTarget(opts: {
readonly path: string;
readonly refusal: () => Error;
}): Promise<Stats | null> {
const observed = await lstat(opts.path).catch((error: unknown) => {
if (isMissingEntryError(error)) {
return null;
}
throw error;
});
if (observed && !observed.isFile()) {
throw opts.refusal();
}
return observed;
}

/**
* Reads a private file that is later replaced with
* {@link replacePrivateFileAtomic}. Returns null when the entry is absent. A
* symlink at the path, a non-regular entry, a symlinked or replaced directory
* from `directories`, or a file that changes identity during the read throws
* `refusal()`; other filesystem errors (for example EACCES) propagate, so an
* unreadable file is never mistaken for an empty one and overwritten.
*/
export async function readReplaceablePrivateFile(
opts: ReplaceablePrivateFileOptions
): Promise<ReplaceablePrivateFile | null> {
const directories = await captureRealDirectories(opts);
const observed = await observeReplaceableTarget(opts);
if (!observed) {
await recheckRealDirectories({ directories, refusal: opts.refusal });
return null;
}
let file: FileHandle;
try {
file = await open(
opts.path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
);
} catch (error: unknown) {
if (isRecord(error) && error.code === "ELOOP") {
throw opts.refusal();
}
throw error;
}
try {
const opened = await file.stat();
if (
!opened.isFile() ||
opened.dev !== observed.dev ||
opened.ino !== observed.ino
) {
throw opts.refusal();
}
const text = await file.readFile({ encoding: "utf8" });
await recheckRealDirectories({ directories, refusal: opts.refusal });
return { text, mode: opened.mode & 0o7777 };
} finally {
await file.close();
}
}

/**
* Replaces `path` with `text` atomically and durably, leaving mode 0600.
*
* A uniquely named sibling is created with O_CREAT|O_EXCL|O_NOFOLLOW and mode
* 0600, fchmod 0600, written and fsynced, then renamed over `path`; readers see
* either the previous or the complete new contents, never a partial file. A
* symlink (dangling or not) or non-regular entry at `path`, or a symlinked or
* replaced directory from `directories`, throws `refusal()` before any write
* reaches the target. rename(2) never follows a final-component symlink, so
* even a link planted after the check is replaced rather than written through.
* The staging file is removed on every failure path.
*/
export async function replacePrivateFileAtomic(
opts: ReplaceablePrivateFileOptions & { readonly text: string }
): Promise<void> {
const directories = await captureRealDirectories(opts);
await observeReplaceableTarget(opts);
const staging = join(
dirname(opts.path),
`.${basename(opts.path)}.${randomUUID()}.tmp`
);
const file = await open(
staging,
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW |
constants.O_WRONLY,
0o600
);
let committed = false;
try {
// The umask can only remove bits; restore owner read/write explicitly.
await file.chmod(0o600);
await file.writeFile(opts.text, "utf8");
await file.sync();
await recheckRealDirectories({ directories, refusal: opts.refusal });
await observeReplaceableTarget(opts);
await rename(staging, opts.path);
committed = true;
} finally {
await file.close();
if (!committed) {
await unlink(staging).catch(() => undefined);
}
}
await syncDirectory({ path: dirname(opts.path) });
}

/** Best effort: persist the directory entry where the platform allows it. */
async function syncDirectory(opts: { readonly path: string }): Promise<void> {
let directory: FileHandle | null = null;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/project-env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
acquireNativeManagedEnvFile,
assertNativeProjectInputRoot,
} from "./native-project-inputs.ts";
import { writePrivateEnvFileIfChanged } from "./private-env-file.ts";
import {
createPrivateFileExclusive,
decodeCanonicalBase64,
Expand Down Expand Up @@ -2398,7 +2399,10 @@ export async function materializeProjectEnv(opts: {
const envPath = resolve(opts.projectDir, PROJECT_ENV_FILENAME);
const text = serializeDotEnv(selectedEnv);
await assertLegacyProjectDirectory({ projectDir: opts.projectDir });
const changed = (await writeTextFileIfChanged(envPath, text)).changed;
// Mode 0600, atomic replace; a symlinked .env or .hack is refused.
const changed = (
await writePrivateEnvFileIfChanged({ envFile: envPath, text })
).changed;
await ensureDir(
dirname(resolveProjectEnvStatePath({ projectDir: opts.projectDir }))
);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/projects-registry-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export async function withProjectsRegistryLock<T>(opts: {
readonly waitForLock?: boolean;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
/** Fixed resource name for the timeout message; defaults to the registry. */
readonly label?: string;
}): Promise<T> {
opts.signal?.throwIfAborted();
if (opts.waitForLock === false) {
Expand Down Expand Up @@ -102,6 +104,7 @@ async function acquire(opts: {
readonly waitForLock?: boolean;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly label?: string;
}): Promise<void> {
const started = performance.now();
const timeout = opts.timeoutMs ?? WAIT_MS;
Expand All @@ -121,7 +124,7 @@ async function acquire(opts: {
? "; recovery guard is busy (interrupted recovery requires offline inspection)"
: "";
throw new Error(
`Timed out waiting for projects registry lock${recovery}`
`Timed out waiting for ${opts.label ?? "projects registry"} lock${recovery}`
);
}
// Jitter prevents a burst of CLI clients from retrying in lockstep.
Expand Down
Loading
Loading