身份不是请求体字段,而是 server-owned principal
Halofy 身份/权限源码拆解:API key hash、server-owned identity、row-level write checks、OAuth tool gate 与非 oracle read。
观察结论
它不是又一个 prompt memory 插件,而是把身份、ACL、写入验证、检索驱动、审计链和合规删除放进同一个内核边界。
Key 发行只暴露一次明文
源码判断
src/auth/index.ts:39 key 表的 schema 直接承载 role 与 namespace,权限判定不用信任请求体里的租户字段。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:73 key hashing 是第一处凭证最小化,数据库只保存可匹配摘要而不是 bearer secret。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:99 oversight role 抽象让 audit/admin 入口可共享语义,而不是散落字符串判断。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:146 rowAgentCanWrite 体现行级写入边界,agent 不能靠传入 actor 改变自己的写面。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:224 拒绝也 append audit,把失败授权纳入治理证据。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:272 bootstrap 与 owner 授权分支先判权再签 key,初始配置和常规签发被清楚分开。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:286 issueKey 明文只出现在返回值,插入表的是 keyHash。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/auth/index.ts:331 resolveKey 将禁用、过期、无 principal 的情形转成 denied 审计。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
39export const API_KEYS_DDL = `CREATE TABLE IF NOT EXISTS api_keys (
40 id TEXT PRIMARY KEY, key_hash TEXT NOT NULL UNIQUE,
41 namespace TEXT NOT NULL, actor TEXT NOT NULL,
42 role TEXT NOT NULL CHECK (role IN ('owner','admin','auditor','member','agent')),
43 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked')),
44 principal_purpose TEXT NOT NULL DEFAULT 'general' CHECK (principal_purpose IN ('general','channel_verification','channel_assistant')),
45 created_at TIMESTAMPTZ NOT NULL, last_used TIMESTAMPTZ,
46 source_principals JSONB NOT NULL DEFAULT '[]',
47 source_groups JSONB NOT NULL DEFAULT '[]',
48 -- D50: NULL on a human key type; 'manager'|'member' on an agent credential.
49 agent_access_level TEXT
50 CHECK (agent_access_level IS NULL OR agent_access_level IN ('manager','member')),
51 -- D51: NULL expiry is standing access; a past expiry stops resolving.
52 expires_at TIMESTAMPTZ,
53 description TEXT,
54 -- D54: NULL means "may write" (every pre-D54 agent could); only FALSE restricts.
55 agent_can_write BOOLEAN,
56 created_by TEXT,
57 custodian_user_id TEXT,
58 bearer_ciphertext TEXT,
59 bearer_iv TEXT,
60 bearer_tag TEXT,
61 CHECK (
62 (bearer_ciphertext IS NULL AND bearer_iv IS NULL AND bearer_tag IS NULL)
63 OR
64 (bearer_ciphertext IS NOT NULL AND bearer_iv IS NOT NULL AND bearer_tag IS NOT NULL)
65 )
66)`;
67
68/** Idempotent safety net; module C's numbered migration is authoritative. */
69export async function ensureApiKeysTable(db: Db): Promise<void> {
70 await db.query(API_KEYS_DDL);
71}
72
73export function hashKey(plaintext: string): string {
74 return createHash("sha256").update(plaintext, "utf8").digest("hex");
75}
76
77/** True when `ns` equals `ancestor` or is a slash-delimited descendant of it. */
78export function isSelfOrDescendant(ns: string, ancestor: string): boolean {
79 return ns === ancestor || ns.startsWith(ancestor + "/");
80}
81
82/** Global authority requires both the owner role and the root namespace. */
83export function isRootOwner(identity: Identity): boolean {
84 return identity.role === "owner" && identity.namespace === "";
85} 272 const countRes = await this.db.query(`SELECT COUNT(*) AS n FROM api_keys`);
273 const empty = Number(countRes.rows[0].n) === 0;
274 const callerMayIssue =
275 caller?.role === "owner" &&
276 (isRootOwner(caller) || isSelfOrDescendant(namespace, caller.namespace));
277 if (!empty && !callerMayIssue) {
278 await this.auditDenied(
279 caller?.actor ?? actor,
280 caller?.namespace ?? namespace,
281 `issueKey denied: requires owner identity (caller role: ${caller?.role ?? "none"})`,
282 );
283 throw new AuthzError("issueKey requires an owner identity");
284 }
285
286 const plaintext = "hm_" + randomBytes(20).toString("hex");
287 const id = ulid();
288 const createdAt = new Date().toISOString();
289 // D50: a level is meaningful only for a machine principal. Non-agent rows
290 // store NULL so "not a machine principal" and "least-privileged agent" stay
291 // distinguishable in the table.
292 const agentAccessLevel: AgentAccessLevel | null =
293 role === "agent" ? (entitlements.agentAccessLevel ?? "member") : null;
294 // Migration 76: the issuer is the CALLER, never the actor the credential
295 // acts as (D9) — and it is null on the first-boot bootstrap path, which by
296 // definition has no caller.
297 const createdBy = caller?.actor ?? null;
298 await this.db.query(
299 `INSERT INTO api_keys
300 (id, key_hash, namespace, actor, role, status, principal_purpose,
301 created_at, last_used, source_principals, source_groups, agent_access_level,
302 created_by)
303 VALUES ($1, $2, $3, $4, $5, 'active', 'general', $6, NULL, $7::jsonb, $8::jsonb, $9, $10)`,
304 [id, hashKey(plaintext), namespace, actor, role, createdAt,
305 JSON.stringify(entitlements.sourcePrincipals ?? []),
306 JSON.stringify(entitlements.sourceGroups ?? []),
307 agentAccessLevel, createdBy],
308 );工具授权和 operation 授权叠加
源码判断
src/syscalls/index.ts:16 角色矩阵写在 syscall 注释中,auditor 可以读/查/组装/故障召回但不能写。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/syscalls/index.ts:124 依赖注入把 credential、policy、budget、audit、queue 合并到同一 runtime,权限不是每个 handler 自己发明。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/syscalls/index.ts:250 source token 不进日志与审计,短期连接授权和长期记忆数据隔离。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/syscalls/index.ts:380 read denied 被映射为非 oracle 响应,调用者看不到隐藏对象是否存在。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/syscalls.test.ts:386 syscalls 测试枚举 write/search/read/fault/assemble/stats 的审计覆盖。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/oauth-connect.test.ts:479 OAuth 连接测试确认 mem_assemble 是 assistant-only,入口处就收束调用者类型。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/r5-integration.test.ts:74 R5 集成测试关注 mem.read 不能成为跨 namespace 存在性 oracle。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/access-projection/index.ts:131 projection 把 contextAssemble 映射为 context:assemble,前端/控制台显示和内核能力统一命名。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/http/adapter.ts:251 assembleSchema 在 HTTP 适配层解析请求,但身份仍来自已解析 principal。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/http/server.ts:1414 server 的路由表把 assemble 映射到 memAssemble,HTTP 只是传输外壳。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
1/**
2 * HaloMem syscall layer (M1 — D9 identity model).
3 *
4 * BREAKING (CONTRACTS-M1.md): syscalls no longer accept `namespace`/`actor`
5 * from the caller. Every syscall takes a resolved `Identity` — namespace and
6 * actor come ONLY from the credential the kernel resolved. No request path
7 * reaches MemoryIndex without one.
8 *
9 * Per-syscall pipeline:
10 * metrics.time(op) → role check → policy fetch (PolicyStore, async) →
11 * rate limit (TokenBucket per keyId, denial → 429-style RateLimitError +
12 * audit row) → op body → audit.
13 *
14 * Role rules (CONTRACTS-M1.md):
15 * agent/member → syscalls only (admin ops enforced in transport/admin layer)
16 * auditor → read/search/stats/assemble/fault allowed, memWrite DENIED
17 * admin/owner → everything
18 *
19 * Budget brownout (D module): when the namespace subtree is over its monthly
20 * USD cap, mem_write degrades to verbatim (no LLM) and the degradation is
21 * logged + audited — the agent is never hard-failed. An LLM circuit-open
22 * (LlmUnavailableError) likewise falls back to verbatim storage; an embedder
23 * circuit-open during search falls back to the BM25-only lane.
24 *
25 * D4: EVERY syscall produces at least one audit row per logical outcome,
26 * including denied / miss / error (write/fault/assemble audit inside their
27 * modules; denial and error paths audit here).
28 */ 124export interface SyscallDeps {
125 config: KernelConfig;
126 db: Db;
127 index: MemoryIndex;
128 audit: AuditLog;
129 cold: ColdStore;
130 metrics: MetricsRegistry;
131 rateLimiter: {
132 allow(key: string, limit: number): boolean | Promise<boolean>;
133 /**
134 * Durable stores expose the full outcome so a denial can carry its
135 * retry-after seconds out to the transport (429 + `retry-after` header).
136 * Optional: in-process buckets and test fakes implement `allow` only and
137 * fall back to a denial without timing.
138 */
139 consume?(
140 bucketKey: string,
141 limit: number,
142 windowMs: number,
143 ): Promise<{ allowed: boolean; retryAfterSec: number }>;
144 };
145 budget: BudgetGuard;
146 logger: Logger;
147 /** Optional commercial quota clamp. Standalone builds leave this absent. */
148 quotaRateLimit?(current: number, actor?: string): number;
149 /**
150 * Durable credential-state check. Identity objects may outlive the request
151 * that resolved them (notably stdio MCP), so no syscall may trust the
152 * snapshot without re-checking the backing key row.
153 */
154 isCredentialActive(identity: Identity): Promise<boolean>;
155 /** Effective (merged, cached) policy for a namespace — PolicyStore.get. */
156 getPolicy(namespace: Namespace): Promise<PolicyConfigX>;
157 /**
158 * M4: resolve the owning Tenant of a namespace (its root), or null when the
159 * root is unregistered. The gate uses this to deny suspended tenants and to
160 * lower the effective rate-limit/budget caps; a null tenant leaves M1/M2/M3
161 * behavior unchanged.
162 */
163 resolveTenant(namespace: Namespace): Promise<Tenant | null>;
164 /**
165 * M4: the process-wide ed25519 signing key (loaded once at boot). Handed to
166 * the erasure engine so every DeletionCertificate shares one public key.
167 */
168 signingKey: SigningKey;
169 /** Namespace-scoped resilient runtime (cost attribution + per-ns breakers). */
170 runtimeFor(namespace: Namespace): NamespaceRuntime;
171 /** Async write lane (WriteQueue) — returns a ticket immediately. */
172 enqueueWrite(
173 req: WriteRequest,
174 identity: Identity,
175 agentId: string,
176 ): Promise<{ ticket: string }>;
177 /** M2: mounted retriever drivers (unknown names → baseline + warn). */
178 registry: DriverRegistrySlice;
179 /** M2: ACL-scoped read fence for a namespace (module A's buildScopedView). */
180 buildView(
181 namespace: Namespace,
182 access?: AccessSubject,
183 sourceProjection?: RetrievalSourceProjection,
184 ): ScopedView;隐藏对象不能变成存在性信号
源码判断
src/kernel.ts:8445 kernel 在 memAssemble 前 requireOAuthTool,工具级授权挡在操作执行之前。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/kernel.ts:8475 同一段附近继续 requireAgentOperation,工具授权和 operation 授权叠加,而不是二选一。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/m1-exit.test.ts:485 M1 exit 测试包含 mem_assemble 能力,说明早期验收就把上下文组装作为受控 syscall。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
380export interface MemReadArgs {
381 id: string;
382}
383
384export interface MemReadResult {
385 found: boolean;
386 /**
387 * Always false in caller-facing results: an exists-but-forbidden row is
388 * indistinguishable from a miss so callers cannot probe for the existence
389 * of rows in namespaces they are denied. The audit trail still records
390 * 'denied' distinctly from 'miss' (D4).
391 */
392 denied: boolean;
393 memory: MemoryObject | null;
394}