Skip to content
Closed
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
8 changes: 6 additions & 2 deletions .ai/skills/hack-repo-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ every `bun test` run: a test or file that leaves the environment, cwd, umask,
exit code, a guarded global function or a process listener changed fails with a
`[global-state-guard]` message naming the file. The guard restores the
environment, cwd, umask and exit code so later files run clean; it keeps
replaced functions and listeners, which may still protect unsettled work. Fix
the named file's restore (`try`/`finally` or `afterEach`), not the later failures.
replaced functions, which may still protect unsettled work. It keeps a test's
leftover listeners until its file ends, then puts the file's listeners back, so
a later `process.emit` is not blamed for them. Fix the named file's restore
(`try`/`finally` or `afterEach`), not the later failures. To emit a real signal
in a test, use `withOwnedProcessListeners` from
`tests/helpers/owned-process-listeners.ts`.
Restore environment keys with `restoreEnv` from `tests/helpers/env.ts`: Bun keeps
a deleted `HTTP_PROXY`-style key for `fetch` and `Bun.spawn` without `env`, and
the guard reports that as `native env <KEY> changed`.
Expand Down
7 changes: 5 additions & 2 deletions docs/reference/native-compose-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,11 @@ expectation admission/write, journal, enrollment read, provision, root discovery
seed, verification, closing root proof, completion write and publication. Carrier
transport additionally distinguishes target, create, policy, start, response and
cleanup; policy reasons identify shape, command, labels, host policy, mount
cardinality, program/storage mounts and policy stability. A policy refusal keeps
its policy reason even when it is caught after the carrier's deadline. A timeout,
cardinality, program/storage mounts and policy stability. A re-inspected carrier
whose creation time or policy changed reports `helper-policy-stability`, and a
saved material binding that changed during a carrier step reports
`private-state`. A policy or state refusal keeps its reason even when it is
caught after the carrier's deadline. A timeout,
budget or otherwise unclassified refusal that finds its carrier's storage budget
exhausted, and not cancelled, reports `storage-budget`, which names
`HACK_NATIVE_STORAGE_TIMEOUT_MS`; a start phase
Expand Down
9 changes: 6 additions & 3 deletions docs/reference/native-compose-storage-xattr-carrier.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,12 @@ Budgets are layered and all finite:
A timeout, budget or otherwise unclassified refusal that finds its carrier's own
deadline passed (and the carrier not cancelled) reports the closed effect reason
`storage-budget`; a probe clipped to the remaining budget times out exactly at
that end. A refusal with its own observed reason, such as a helper policy
refusal, keeps that reason even when it is caught after the deadline, including
when a recorded command replaces it with its fixed command refusal. Cancellation
that end. A refusal with its own observed reason keeps that reason even when it
is caught after the deadline, including when a recorded command or its command
record replaces it with a fixed refusal. Such reasons include a helper policy
refusal, `helper-policy-stability` for a re-inspected carrier whose creation
time or policy changed, and `private-state` for a saved material binding that
changed during a carrier step. Cancellation
keeps `probe-cancel`. The reason changes no check, retention or recovery rule.

Only the original in-memory writer can advance its exact sequence. Publication
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/project/compose/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use serde_json::{Map, Value};
mod dependency_cache;
mod fields;
mod graph;
mod ownership_labels;
pub(super) mod routing;
mod service;
use fields::*;
Expand Down
63 changes: 63 additions & 0 deletions packages/runtime-core/src/project/compose/ownership_labels.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Label namespaces that Hack writes as resource ownership evidence.

/// Container, network and volume label namespaces that Hack writes to prove
/// which resources it owns. This mirrors `HACK_OWNERSHIP_LABEL_NAMESPACES` in
/// `src/lib/hack-ownership-labels.ts`, the source of truth;
/// `tests/hack-ownership-labels.test.ts` fails when the two lists differ.
///
/// A project must not declare a label in these namespaces, because the native
/// runtime and native-config Compose trust them as ownership evidence. The
/// TypeScript classifier rejects such labels first; the candidate's own label
/// review rejects them again so a bypassed classifier cannot pass one through.
pub(super) const HACK_OWNERSHIP_LABEL_NAMESPACES: [&str; 4] = [
"io.hack-local.",
"io.hack.native-config.",
"io.hack.native-file.",
"io.hack.storage-witness.",
];

/// Whether `key` is inside a Hack ownership namespace or is a namespace root
/// without its trailing dot (for example `io.hack-local`).
pub(super) fn is_hack_ownership_label(key: &str) -> bool {
HACK_OWNERSHIP_LABEL_NAMESPACES
.iter()
.any(|namespace| key.starts_with(namespace) || namespace.strip_suffix('.') == Some(key))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ownership_namespaces_and_roots_are_hack_ownership_labels() {
for key in [
"io.hack-local.owner",
"io.hack-local",
"io.hack.native-config.owner",
"io.hack.native-config",
"io.hack.native-file.token",
"io.hack.native-file",
"io.hack.storage-witness.carrier",
"io.hack.storage-witness",
] {
assert!(is_hack_ownership_label(key), "{key}");
}
}

#[test]
fn near_miss_keys_are_not_hack_ownership_labels() {
for key in [
"io.hack",
"io.hack.",
"io.hacker.owner",
"io.hack-localx.owner",
"io.hack.native-configx",
"io.hack.storage",
"io.hack.custom",
"example.io.hack-local.owner",
"IO.HACK-LOCAL.OWNER",
] {
assert!(!is_hack_ownership_label(key), "{key}");
}
}
}
13 changes: 8 additions & 5 deletions packages/runtime-core/src/project/compose/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,11 +805,14 @@ pub(super) fn compile(
}
if routing_invalid
|| labels.keys().any(|key| {
!(dependency_cache.is_some() && super::dependency_cache::recognized(key))
&& !(routing.is_some() && super::routing::recognized(key))
&& ["caddy", "traefik", "hack", "com.docker.compose"]
.iter()
.any(|prefix| key == prefix || key.starts_with(&format!("{prefix}.")))
// Hack's ownership namespaces are never exempt: no routing or
// dependency cache declaration may carry ownership evidence.
super::ownership_labels::is_hack_ownership_label(key)
|| (!(dependency_cache.is_some() && super::dependency_cache::recognized(key))
&& !(routing.is_some() && super::routing::recognized(key))
&& ["caddy", "traefik", "hack", "com.docker.compose"]
.iter()
.any(|prefix| key == prefix || key.starts_with(&format!("{prefix}."))))
})
{
diagnostics.push(Diagnostic::error(
Expand Down
55 changes: 55 additions & 0 deletions packages/runtime-core/tests/project_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,61 @@ fn unsupported_privileged_external_and_unknown_fields_block_enrollment() {
assert!(fixture.codes().contains(&"external_network".into()));
}

/// The candidate's own label review rejects Hack's ownership namespaces (the
/// list `HACK_OWNERSHIP_LABEL_NAMESPACES` declares in TypeScript), so a
/// project cannot carry ownership evidence even when the TypeScript classifier
/// is bypassed. Routing and dependency cache declarations never exempt them.
#[test]
fn hack_ownership_label_namespaces_block_enrollment() {
let owner_label = |fixture: &Fixture| {
fixture
.codes()
.iter()
.any(|code| code == "external_route_or_owner_label")
};
for key in [
"io.hack-local.owner",
"io.hack.native-config.owner",
"io.hack.native-file.token",
"io.hack.storage-witness.carrier",
] {
for labels in [
format!(" labels: {{'{key}': owner-canary}}\n"),
format!(" labels: ['{key}=owner-canary']\n"),
format!(
" labels:\n caddy: web.hack\n caddy.reverse_proxy: '{{{{upstreams 3000}}}}'\n caddy.tls: internal\n '{key}': owner-canary\n"
),
] {
let fixture = Fixture::new(&format!("{BASIC}{labels}"));
let plan = fixture.plan();
assert!(owner_label(&fixture), "{key}: {labels}");
assert!(!plan.plan.enrollment_compatible, "{key}");
assert!(
!serde_json::to_string(&plan)
.unwrap()
.contains("owner-canary")
);
assert_eq!(
project::enroll(&fixture.candidate, fixture.options(), &plan.plan_id)
.unwrap_err()
.code,
"incompatible_compose"
);
assert!(!fixture.candidate.state_root.exists());
}
}
// Near misses outside every ownership namespace stay reviewable. Closer
// misses (a longer first segment, or another segment under the same
// reverse-DNS root) are pinned by the `ownership_labels` unit tests; they
// stay out of this file because the TypeScript parity scan reads every
// such key here as a label Hack writes.
let fixture = Fixture::new(&format!(
"{BASIC} labels: {{example.io.hack-local.owner: a, io-hack-local.owner: b, io_hack.native-config.owner: c}}\n"
));
assert!(!owner_label(&fixture));
assert!(fixture.plan().plan.enrollment_compatible);
}

#[test]
fn explicit_external_network_replacement_is_owned_internal_and_review_bound() {
let original =
Expand Down
6 changes: 5 additions & 1 deletion src/lib/hack-ownership-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
* (`owner`, `generation`, `helper`, `carrier`).
*
* A project must not declare a label in these namespaces, because their
* consumers trust them as ownership evidence. Add a namespace here before
* consumers trust them as ownership evidence. The TypeScript Compose
* classifier rejects them, and runtime-core's own label review rejects them
* again from a Rust mirror of this list
* (`packages/runtime-core/src/project/compose/ownership_labels.rs`) that the
* same test keeps identical. Add a namespace here, and to the mirror, before
* writing a label in it.
*/
export const HACK_OWNERSHIP_LABEL_NAMESPACES = [
Expand Down
47 changes: 36 additions & 11 deletions src/lib/native-compose-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,32 @@ async function finalizeNativeComposeStop(opts: {
}
}

/**
* The pre-spawn guard's storage proof in a native `up`/`restart` start phase.
* Only each carrier proof (carrier creation and witness verification) runs in
* the start deadline's storage exclusion, passed to `verify` as `excludeProof`.
* The proof's freshness checks and saved-state reads around it consume the
* start budget like any other start-phase work. `takeAdjacentProof` answers,
* once per proof, whether this guard may reuse the immediately preceding proof.
*/
export function nativeComposeGuardStorageProof<G>(opts: {
readonly verify: (
generation: G,
options: NativeComposeCommandStorageVerifyOptions
) => Promise<void>;
readonly generation: G;
readonly excludeStorage: <T>(work: () => Promise<T>) => Promise<T>;
readonly takeAdjacentProof: () => boolean;
}): () => Promise<void> {
return () => {
const adjacentProof = opts.takeAdjacentProof();
return opts.verify(opts.generation, {
...(adjacentProof ? { adjacentProof } : {}),
excludeProof: opts.excludeStorage,
});
};
}

export async function assertNativeComposeEffectOwned(opts: {
readonly assertFresh: () => Promise<void>;
readonly assertOwned: () => Promise<void>;
Expand Down Expand Up @@ -1724,17 +1750,16 @@ async function executePreparedGeneration(opts: {
assertFresh,
assertOwned: () => ownership.assertOwned(),
verifyStorage: storage
? () => {
const adjacentProof = adjacentGuardProof;
adjacentGuardProof = false;
// Only each carrier proof is excluded; the guard's
// freshness checks and saved-state reads consume the
// start budget like any other start-phase work.
return storage.verify(generation, {
...(adjacentProof ? { adjacentProof } : {}),
excludeProof: start.excludeStorage,
});
}
? nativeComposeGuardStorageProof({
verify: storage.verify,
generation,
excludeStorage: start.excludeStorage,
takeAdjacentProof: () => {
const adjacentProof = adjacentGuardProof;
adjacentGuardProof = false;
return adjacentProof;
},
})
: undefined,
}),
});
Expand Down
20 changes: 16 additions & 4 deletions src/lib/native-compose-storage-command-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
import { rename } from "node:fs/promises";
import { join } from "node:path";
import { isRecord } from "./guards.ts";
import { copyNativeComposeEffectRefusal } from "./native-compose-effect-diagnostics.ts";
import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts";
import {
type HeldDirectory,
Expand Down Expand Up @@ -382,6 +383,17 @@ type OwnerState = {
export type NativeComposeStorageCommandOwner = Readonly<Record<never, never>>;
export type NativeComposeStorageArmedCommand = Readonly<Record<never, never>>;
const owners = new WeakMap<NativeComposeStorageCommandOwner, OwnerState>();
/** The same fixed refusal. Only an issued owner boundary of `source`, such as an
* owner check's saved-state refusal, is kept for the effect diagnostic; it grants
* nothing and changes no check, invalidation or retention. */
function refuseFrom(source: unknown): never {
try {
return refuse();
} catch (error) {
copyNativeComposeEffectRefusal(source, error as Error);
throw error;
}
}
const commands = new WeakMap<
NativeComposeStorageArmedCommand,
{
Expand Down Expand Up @@ -439,9 +451,9 @@ async function publish(owner: OwnerState, record: RecordV2): Promise<void> {
owner.saved = saved;
owner.record = record;
owner.published(saved.info);
} catch {
} catch (error) {
owner.active = false;
return refuse();
return refuseFrom(error);
} finally {
owner.busy = false;
}
Expand Down Expand Up @@ -490,8 +502,8 @@ export async function createNativeComposeStorageCommandOwner(opts: {
});
published(saved.info);
return handle;
} catch {
return refuse();
} catch (error) {
return refuseFrom(error);
}
}
export async function armNativeComposeStorageCommand(opts: {
Expand Down
Loading
Loading