@mnemom/mnemom 0.17.32 → 0.17.34-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent-fuel.d.ts +9 -0
- package/dist/commands/agent-fuel.js +36 -2
- package/dist/commands/agent-run.d.ts +50 -0
- package/dist/commands/agent-run.js +221 -0
- package/dist/commands/agent-setup.d.ts +8 -3
- package/dist/commands/agent-setup.js +32 -9
- package/dist/commands/agent-share-fixture.d.ts +3 -1
- package/dist/commands/agent-share-fixture.js +11 -7
- package/dist/commands/agent-share-render.d.ts +23 -0
- package/dist/commands/agent-share-render.js +72 -0
- package/dist/commands/agent-share.d.ts +15 -2
- package/dist/commands/agent-share.js +30 -9
- package/dist/commands/agent.d.ts +34 -4
- package/dist/commands/agent.js +116 -20
- package/dist/commands/protection.js +1 -1
- package/dist/commands/scan.d.ts +97 -0
- package/dist/commands/scan.js +238 -0
- package/dist/index.js +55 -0
- package/dist/lib/api.d.ts +13 -0
- package/dist/lib/api.js +25 -1
- package/dist/lib/auth.d.ts +6 -0
- package/dist/lib/auth.js +161 -14
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +13 -0
- package/dist/lib/oauth.d.ts +7 -0
- package/dist/lib/oauth.js +13 -0
- package/dist/lib/runner/bundle.d.ts +129 -0
- package/dist/lib/runner/bundle.js +319 -0
- package/dist/lib/runner/contract-header.d.ts +46 -0
- package/dist/lib/runner/contract-header.js +85 -0
- package/dist/lib/runner/executor-client.d.ts +71 -0
- package/dist/lib/runner/executor-client.js +97 -0
- package/dist/lib/runner/job.d.ts +70 -0
- package/dist/lib/runner/job.js +463 -0
- package/dist/lib/runner/loop.d.ts +45 -0
- package/dist/lib/runner/loop.js +104 -0
- package/dist/lib/runner/oidc.d.ts +38 -0
- package/dist/lib/runner/oidc.js +89 -0
- package/dist/lib/runner/options.d.ts +41 -0
- package/dist/lib/runner/options.js +47 -0
- package/dist/lib/runner/outcome.d.ts +47 -0
- package/dist/lib/runner/outcome.js +89 -0
- package/dist/lib/runner/phases.d.ts +49 -0
- package/dist/lib/runner/phases.js +99 -0
- package/dist/lib/runner/proxy.d.ts +45 -0
- package/dist/lib/runner/proxy.js +153 -0
- package/dist/lib/runner/settings.d.ts +31 -0
- package/dist/lib/runner/settings.js +44 -0
- package/dist/lib/scan/caps.d.ts +35 -0
- package/dist/lib/scan/caps.js +67 -0
- package/dist/lib/scan/credential.d.ts +37 -0
- package/dist/lib/scan/credential.js +104 -0
- package/dist/lib/scan/failure.d.ts +10 -0
- package/dist/lib/scan/failure.js +43 -0
- package/dist/lib/scan/pr-source.d.ts +57 -0
- package/dist/lib/scan/pr-source.js +130 -0
- package/dist/lib/scan/record.d.ts +49 -0
- package/dist/lib/scan/record.js +85 -0
- package/dist/lib/scan/report-email.d.ts +92 -0
- package/dist/lib/scan/report-email.js +136 -0
- package/dist/lib/scan/report.d.ts +13 -0
- package/dist/lib/scan/report.js +30 -0
- package/dist/lib/scan/scan.d.ts +64 -0
- package/dist/lib/scan/scan.js +182 -0
- package/dist/lib/scan/share.d.ts +38 -0
- package/dist/lib/scan/share.js +47 -0
- package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
- package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
- package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
- package/dist/lib/scan/spend/anthropic-usage.js +244 -0
- package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
- package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
- package/dist/lib/scan/spend/prices.d.ts +33 -0
- package/dist/lib/scan/spend/prices.js +57 -0
- package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
- package/dist/lib/scan/spend/spend-estimate.js +70 -0
- package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
- package/dist/lib/scan/spend/spend-tiers.js +42 -0
- package/dist/lib/scan/spend/token-rate.d.ts +30 -0
- package/dist/lib/scan/spend/token-rate.js +54 -0
- package/dist/lib/session-key.d.ts +41 -0
- package/dist/lib/session-key.js +124 -0
- package/dist/lib/statusline-feed.js +10 -11
- package/dist/lib/statusline.d.ts +6 -0
- package/dist/lib/statusline.js +19 -9
- package/dist/rc-key-401.mjs +75 -0
- package/dist/rc-proxy.mjs +290 -30
- package/package.json +2 -1
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -26,6 +26,13 @@
|
|
|
26
26
|
* positive integer (capped at 5 min), else the default.
|
|
27
27
|
*/
|
|
28
28
|
export declare function oauthHttpTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
29
|
+
/**
|
|
30
|
+
* The longest one refreshTokens() call can legitimately take (ms): discovery
|
|
31
|
+
* plus REFRESH_MAX_ATTEMPTS token requests, each at the per-request deadline,
|
|
32
|
+
* plus the backoff sleeps between attempts. lib/auth.ts uses it to bound how
|
|
33
|
+
* long a process waits on another process's refresh lock (MNE-9232).
|
|
34
|
+
*/
|
|
35
|
+
export declare function refreshWorstCaseMs(env?: NodeJS.ProcessEnv): number;
|
|
29
36
|
/** The name carried by an error thrown when an AS round-trip hit its deadline. */
|
|
30
37
|
export declare const OAUTH_TIMEOUT_ERROR = "OAuthTimeoutError";
|
|
31
38
|
export interface AuthServerMetadata {
|
package/dist/lib/oauth.js
CHANGED
|
@@ -86,6 +86,19 @@ export function oauthHttpTimeoutMs(env = process.env) {
|
|
|
86
86
|
}
|
|
87
87
|
return OAUTH_HTTP_TIMEOUT_MS;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The longest one refreshTokens() call can legitimately take (ms): discovery
|
|
91
|
+
* plus REFRESH_MAX_ATTEMPTS token requests, each at the per-request deadline,
|
|
92
|
+
* plus the backoff sleeps between attempts. lib/auth.ts uses it to bound how
|
|
93
|
+
* long a process waits on another process's refresh lock (MNE-9232).
|
|
94
|
+
*/
|
|
95
|
+
export function refreshWorstCaseMs(env = process.env) {
|
|
96
|
+
let backoff = 0;
|
|
97
|
+
for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS - 1; attempt++) {
|
|
98
|
+
backoff += REFRESH_BACKOFF_BASE_MS * 2 ** attempt;
|
|
99
|
+
}
|
|
100
|
+
return oauthHttpTimeoutMs(env) * (1 + REFRESH_MAX_ATTEMPTS) + backoff;
|
|
101
|
+
}
|
|
89
102
|
/** The name carried by an error thrown when an AS round-trip hit its deadline. */
|
|
90
103
|
export const OAUTH_TIMEOUT_ERROR = "OAuthTimeoutError";
|
|
91
104
|
function isTimeout(err) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Served phase bundle verification for the thin runner (Doors W-L13, W3.11).
|
|
3
|
+
*
|
|
4
|
+
* On each claim the runner fetches one signed `AAP-ServedPhaseBundle/v1` (mnemom-api
|
|
5
|
+
* src/served-phases, W-L12) and runs nothing it did not verify here: the phase order, each
|
|
6
|
+
* phase's prompt and its runner code or capability ids all sit inside the signed bytes, and the
|
|
7
|
+
* CLI package itself holds no prompts.
|
|
8
|
+
*
|
|
9
|
+
* This is the runner-side twin of the api's `verifyServedPhaseBundle`. Both replay the same
|
|
10
|
+
* fixture corpus, so a bundle one side refuses the other refuses too. Checks run in a fixed
|
|
11
|
+
* order and the first failure is the reason: size cap (before parsing), JSON, typ, strict
|
|
12
|
+
* schema (an unknown field at any level refuses), key, signature, code sha256 consistency,
|
|
13
|
+
* clock, then the claim binding.
|
|
14
|
+
*
|
|
15
|
+
* - Signature: detached base64url Ed25519 over `canonicalJson` of every field but `signature`
|
|
16
|
+
* (keys sorted, no whitespace), the construction `AAP-TransparencyRoot/v1` uses. The key is
|
|
17
|
+
* the dedicated served-phase key, resolved by `signing_key_id` from the published JWKS; an
|
|
18
|
+
* ambiguous kid, a key with an unknown status, or a key retired at or before `issued_at`
|
|
19
|
+
* refuses.
|
|
20
|
+
* - Binding: the claim is (session_id, lease epoch, executor_id). A genuine bundle lifted from
|
|
21
|
+
* another claim still verifies cryptographically, so the caller passes everything it knows
|
|
22
|
+
* about its own claim in `expect`, and every field must match.
|
|
23
|
+
*
|
|
24
|
+
* Pure apart from `node:crypto`: no I/O, never throws.
|
|
25
|
+
*/
|
|
26
|
+
import { canonicalJson } from "../canonical-json.js";
|
|
27
|
+
export { canonicalJson };
|
|
28
|
+
export declare const SERVED_PHASE_BUNDLE_TYP = "AAP-ServedPhaseBundle/v1";
|
|
29
|
+
/** Upper bound on a served bundle, so the runner never parses or hashes an unbounded body. */
|
|
30
|
+
export declare const MAX_BUNDLE_BYTES: number;
|
|
31
|
+
export declare const MAX_PHASES = 32;
|
|
32
|
+
export declare const MAX_PROMPT_BYTES: number;
|
|
33
|
+
export declare const MAX_CODE_BYTES: number;
|
|
34
|
+
export declare const MAX_CAPABILITIES_PER_PHASE = 32;
|
|
35
|
+
export declare const MAX_BUNDLE_LIFETIME_SECONDS: number;
|
|
36
|
+
export type MergeStrategy = "external" | "merge_on_green";
|
|
37
|
+
export type ServedPhaseRunner = {
|
|
38
|
+
kind: "capabilities";
|
|
39
|
+
capability_ids: string[];
|
|
40
|
+
} | {
|
|
41
|
+
kind: "code";
|
|
42
|
+
language: string;
|
|
43
|
+
source: string;
|
|
44
|
+
sha256: string;
|
|
45
|
+
};
|
|
46
|
+
export interface ServedPhase {
|
|
47
|
+
order: number;
|
|
48
|
+
name: string;
|
|
49
|
+
prompt: string;
|
|
50
|
+
runner: ServedPhaseRunner;
|
|
51
|
+
}
|
|
52
|
+
export interface ServedPhaseBundle {
|
|
53
|
+
typ: typeof SERVED_PHASE_BUNDLE_TYP;
|
|
54
|
+
bundle_id: string;
|
|
55
|
+
signing_key_id: string;
|
|
56
|
+
issued_at: string;
|
|
57
|
+
expires_at: string;
|
|
58
|
+
org_id: string;
|
|
59
|
+
claim: {
|
|
60
|
+
session_id: string;
|
|
61
|
+
epoch: number;
|
|
62
|
+
executor_id: string;
|
|
63
|
+
};
|
|
64
|
+
contract: {
|
|
65
|
+
sha256: string;
|
|
66
|
+
version: number;
|
|
67
|
+
};
|
|
68
|
+
workflow: {
|
|
69
|
+
id: string;
|
|
70
|
+
version: string;
|
|
71
|
+
};
|
|
72
|
+
/** Absent means `external`; `resolveMergeStrategy` (settings.ts) applies the default. */
|
|
73
|
+
merge_strategy?: MergeStrategy;
|
|
74
|
+
phases: ServedPhase[];
|
|
75
|
+
signature: string;
|
|
76
|
+
}
|
|
77
|
+
/** One published key, as `/v1/.well-known/jwks.json` lists it. */
|
|
78
|
+
export interface BundleJwk {
|
|
79
|
+
kty?: string;
|
|
80
|
+
crv?: string;
|
|
81
|
+
x?: string;
|
|
82
|
+
kid?: string;
|
|
83
|
+
mnemom_ext?: {
|
|
84
|
+
status?: string;
|
|
85
|
+
retired_at?: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export interface BundleJwks {
|
|
89
|
+
keys: BundleJwk[];
|
|
90
|
+
}
|
|
91
|
+
export type BundleRefusalReason = "too_large" | "malformed" | "wrong_typ" | "schema" | "unknown_key" | "unusable_key" | "retired_key" | "bad_signature" | "code_digest_mismatch" | "not_yet_valid" | "expired" | "binding_mismatch";
|
|
92
|
+
export type VerifyBundleResult = {
|
|
93
|
+
valid: true;
|
|
94
|
+
bundle: ServedPhaseBundle;
|
|
95
|
+
digest: string;
|
|
96
|
+
key_id: string;
|
|
97
|
+
} | {
|
|
98
|
+
valid: false;
|
|
99
|
+
reason: BundleRefusalReason;
|
|
100
|
+
detail: string;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* What the runner knows about the claim it holds. All six are required, and every one must match
|
|
104
|
+
* the bundle: a partial expectation would silently weaken the binding. The runner knows session,
|
|
105
|
+
* epoch, executor and seal from its claim; it learns org_id only from the server, so the job reads
|
|
106
|
+
* it off the served body and that field checks shape, not an independent fact.
|
|
107
|
+
*/
|
|
108
|
+
export interface ExpectedBundleBinding {
|
|
109
|
+
org_id: string;
|
|
110
|
+
session_id: string;
|
|
111
|
+
epoch: number;
|
|
112
|
+
executor_id: string;
|
|
113
|
+
contract_sha256: string;
|
|
114
|
+
contract_version: number;
|
|
115
|
+
}
|
|
116
|
+
export interface VerifyBundleOptions {
|
|
117
|
+
/** Inject a clock (ms since the epoch); defaults to Date.now. */
|
|
118
|
+
now?: () => number;
|
|
119
|
+
/** Grace on issued_at and expires_at, seconds. Defaults to 60. */
|
|
120
|
+
clockSkewSeconds?: number;
|
|
121
|
+
}
|
|
122
|
+
export declare function sha256Hex(text: string): string;
|
|
123
|
+
/** The exact bytes the signature covers: canonical JSON of every field but `signature`. */
|
|
124
|
+
export declare function signedBytes(bundle: Record<string, unknown>): string;
|
|
125
|
+
/**
|
|
126
|
+
* Verify one served bundle against the published key(s). Never throws: any unexpected failure
|
|
127
|
+
* is a refusal, never a pass.
|
|
128
|
+
*/
|
|
129
|
+
export declare function verifyServedPhaseBundle(served: string, keyOrJwks: BundleJwk | BundleJwks, expect: ExpectedBundleBinding, options?: VerifyBundleOptions): VerifyBundleResult;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Served phase bundle verification for the thin runner (Doors W-L13, W3.11).
|
|
3
|
+
*
|
|
4
|
+
* On each claim the runner fetches one signed `AAP-ServedPhaseBundle/v1` (mnemom-api
|
|
5
|
+
* src/served-phases, W-L12) and runs nothing it did not verify here: the phase order, each
|
|
6
|
+
* phase's prompt and its runner code or capability ids all sit inside the signed bytes, and the
|
|
7
|
+
* CLI package itself holds no prompts.
|
|
8
|
+
*
|
|
9
|
+
* This is the runner-side twin of the api's `verifyServedPhaseBundle`. Both replay the same
|
|
10
|
+
* fixture corpus, so a bundle one side refuses the other refuses too. Checks run in a fixed
|
|
11
|
+
* order and the first failure is the reason: size cap (before parsing), JSON, typ, strict
|
|
12
|
+
* schema (an unknown field at any level refuses), key, signature, code sha256 consistency,
|
|
13
|
+
* clock, then the claim binding.
|
|
14
|
+
*
|
|
15
|
+
* - Signature: detached base64url Ed25519 over `canonicalJson` of every field but `signature`
|
|
16
|
+
* (keys sorted, no whitespace), the construction `AAP-TransparencyRoot/v1` uses. The key is
|
|
17
|
+
* the dedicated served-phase key, resolved by `signing_key_id` from the published JWKS; an
|
|
18
|
+
* ambiguous kid, a key with an unknown status, or a key retired at or before `issued_at`
|
|
19
|
+
* refuses.
|
|
20
|
+
* - Binding: the claim is (session_id, lease epoch, executor_id). A genuine bundle lifted from
|
|
21
|
+
* another claim still verifies cryptographically, so the caller passes everything it knows
|
|
22
|
+
* about its own claim in `expect`, and every field must match.
|
|
23
|
+
*
|
|
24
|
+
* Pure apart from `node:crypto`: no I/O, never throws.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
27
|
+
import { canonicalJson } from "../canonical-json.js";
|
|
28
|
+
export { canonicalJson };
|
|
29
|
+
export const SERVED_PHASE_BUNDLE_TYP = "AAP-ServedPhaseBundle/v1";
|
|
30
|
+
/** Upper bound on a served bundle, so the runner never parses or hashes an unbounded body. */
|
|
31
|
+
export const MAX_BUNDLE_BYTES = 256 * 1024;
|
|
32
|
+
export const MAX_PHASES = 32;
|
|
33
|
+
export const MAX_PROMPT_BYTES = 64 * 1024;
|
|
34
|
+
export const MAX_CODE_BYTES = 128 * 1024;
|
|
35
|
+
export const MAX_CAPABILITIES_PER_PHASE = 32;
|
|
36
|
+
export const MAX_BUNDLE_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
37
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
|
38
|
+
const ED25519_SIGNATURE_BYTES = 64;
|
|
39
|
+
/** Key statuses the published JWKS uses. Any other value refuses the key. */
|
|
40
|
+
const USABLE_KEY_STATUSES = new Set([undefined, "active", "retired"]);
|
|
41
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
42
|
+
const PHASE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
43
|
+
const CAPABILITY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
44
|
+
const LANGUAGE_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
45
|
+
const WORKFLOW_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
46
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
47
|
+
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
48
|
+
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
49
|
+
const TOP_LEVEL_REQUIRED = [
|
|
50
|
+
"typ",
|
|
51
|
+
"bundle_id",
|
|
52
|
+
"signing_key_id",
|
|
53
|
+
"issued_at",
|
|
54
|
+
"expires_at",
|
|
55
|
+
"org_id",
|
|
56
|
+
"claim",
|
|
57
|
+
"contract",
|
|
58
|
+
"workflow",
|
|
59
|
+
"phases",
|
|
60
|
+
"signature",
|
|
61
|
+
];
|
|
62
|
+
class ShapeError extends Error {
|
|
63
|
+
}
|
|
64
|
+
export function sha256Hex(text) {
|
|
65
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
66
|
+
}
|
|
67
|
+
function utf8Bytes(s) {
|
|
68
|
+
return Buffer.byteLength(s, "utf8");
|
|
69
|
+
}
|
|
70
|
+
function isPlainObject(v) {
|
|
71
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
72
|
+
}
|
|
73
|
+
function exactKeys(obj, keys, where) {
|
|
74
|
+
for (const k of Object.keys(obj)) {
|
|
75
|
+
if (!keys.includes(k))
|
|
76
|
+
throw new ShapeError(`${where}: unexpected field ${JSON.stringify(k)}`);
|
|
77
|
+
}
|
|
78
|
+
for (const k of keys) {
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(obj, k)) {
|
|
80
|
+
throw new ShapeError(`${where}: missing field ${JSON.stringify(k)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function str(v, re, where) {
|
|
85
|
+
if (typeof v !== "string" || !re.test(v))
|
|
86
|
+
throw new ShapeError(`${where}: invalid value`);
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
function boundedText(v, maxBytes, where) {
|
|
90
|
+
if (typeof v !== "string" || v.length === 0)
|
|
91
|
+
throw new ShapeError(`${where}: must be a non-empty string`);
|
|
92
|
+
if (utf8Bytes(v) > maxBytes)
|
|
93
|
+
throw new ShapeError(`${where}: longer than ${maxBytes} bytes`);
|
|
94
|
+
}
|
|
95
|
+
function timestamp(v, where) {
|
|
96
|
+
const ms = Date.parse(str(v, ISO_UTC_RE, where));
|
|
97
|
+
if (!Number.isFinite(ms))
|
|
98
|
+
throw new ShapeError(`${where}: not a valid timestamp`);
|
|
99
|
+
return ms;
|
|
100
|
+
}
|
|
101
|
+
function checkRunner(v, where) {
|
|
102
|
+
if (!isPlainObject(v))
|
|
103
|
+
throw new ShapeError(`${where}: must be an object`);
|
|
104
|
+
if (v.kind === "capabilities") {
|
|
105
|
+
exactKeys(v, ["kind", "capability_ids"], where);
|
|
106
|
+
const ids = v.capability_ids;
|
|
107
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.length > MAX_CAPABILITIES_PER_PHASE) {
|
|
108
|
+
throw new ShapeError(`${where}.capability_ids: must list 1 to ${MAX_CAPABILITIES_PER_PHASE} ids`);
|
|
109
|
+
}
|
|
110
|
+
ids.forEach((id, i) => str(id, CAPABILITY_RE, `${where}.capability_ids[${i}]`));
|
|
111
|
+
if (new Set(ids).size !== ids.length)
|
|
112
|
+
throw new ShapeError(`${where}.capability_ids: duplicate id`);
|
|
113
|
+
}
|
|
114
|
+
else if (v.kind === "code") {
|
|
115
|
+
exactKeys(v, ["kind", "language", "source", "sha256"], where);
|
|
116
|
+
str(v.language, LANGUAGE_RE, `${where}.language`);
|
|
117
|
+
boundedText(v.source, MAX_CODE_BYTES, `${where}.source`);
|
|
118
|
+
str(v.sha256, SHA256_RE, `${where}.sha256`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
throw new ShapeError(`${where}.kind: must be "capabilities" or "code"`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Throws ShapeError unless `v` is exactly a signed v1 bundle (typ is checked by the caller). */
|
|
125
|
+
function assertBundleShape(v) {
|
|
126
|
+
const allowed = [...TOP_LEVEL_REQUIRED, "merge_strategy"];
|
|
127
|
+
for (const k of Object.keys(v)) {
|
|
128
|
+
if (!allowed.includes(k))
|
|
129
|
+
throw new ShapeError(`bundle: unexpected field ${JSON.stringify(k)}`);
|
|
130
|
+
}
|
|
131
|
+
for (const k of TOP_LEVEL_REQUIRED) {
|
|
132
|
+
if (!Object.prototype.hasOwnProperty.call(v, k))
|
|
133
|
+
throw new ShapeError(`bundle: missing field ${JSON.stringify(k)}`);
|
|
134
|
+
}
|
|
135
|
+
str(v.bundle_id, ID_RE, "bundle.bundle_id");
|
|
136
|
+
str(v.signing_key_id, ID_RE, "bundle.signing_key_id");
|
|
137
|
+
str(v.org_id, ID_RE, "bundle.org_id");
|
|
138
|
+
const issued = timestamp(v.issued_at, "bundle.issued_at");
|
|
139
|
+
const expires = timestamp(v.expires_at, "bundle.expires_at");
|
|
140
|
+
if (expires <= issued)
|
|
141
|
+
throw new ShapeError("bundle.expires_at: must be after issued_at");
|
|
142
|
+
if (expires - issued > MAX_BUNDLE_LIFETIME_SECONDS * 1000) {
|
|
143
|
+
throw new ShapeError(`bundle: lifetime longer than ${MAX_BUNDLE_LIFETIME_SECONDS} s`);
|
|
144
|
+
}
|
|
145
|
+
if (!isPlainObject(v.claim))
|
|
146
|
+
throw new ShapeError("bundle.claim: must be an object");
|
|
147
|
+
exactKeys(v.claim, ["session_id", "epoch", "executor_id"], "bundle.claim");
|
|
148
|
+
str(v.claim.session_id, ID_RE, "bundle.claim.session_id");
|
|
149
|
+
const epoch = v.claim.epoch;
|
|
150
|
+
if (typeof epoch !== "number" || !Number.isSafeInteger(epoch) || epoch < 0) {
|
|
151
|
+
throw new ShapeError("bundle.claim.epoch: must be a non-negative integer");
|
|
152
|
+
}
|
|
153
|
+
str(v.claim.executor_id, ID_RE, "bundle.claim.executor_id");
|
|
154
|
+
if (!isPlainObject(v.contract))
|
|
155
|
+
throw new ShapeError("bundle.contract: must be an object");
|
|
156
|
+
exactKeys(v.contract, ["sha256", "version"], "bundle.contract");
|
|
157
|
+
str(v.contract.sha256, SHA256_RE, "bundle.contract.sha256");
|
|
158
|
+
const version = v.contract.version;
|
|
159
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) {
|
|
160
|
+
throw new ShapeError("bundle.contract.version: must be a positive integer");
|
|
161
|
+
}
|
|
162
|
+
if (!isPlainObject(v.workflow))
|
|
163
|
+
throw new ShapeError("bundle.workflow: must be an object");
|
|
164
|
+
exactKeys(v.workflow, ["id", "version"], "bundle.workflow");
|
|
165
|
+
str(v.workflow.id, ID_RE, "bundle.workflow.id");
|
|
166
|
+
str(v.workflow.version, WORKFLOW_VERSION_RE, "bundle.workflow.version");
|
|
167
|
+
if ("merge_strategy" in v &&
|
|
168
|
+
v.merge_strategy !== "external" &&
|
|
169
|
+
v.merge_strategy !== "merge_on_green") {
|
|
170
|
+
throw new ShapeError('bundle.merge_strategy: must be "external" or "merge_on_green"');
|
|
171
|
+
}
|
|
172
|
+
const phases = v.phases;
|
|
173
|
+
if (!Array.isArray(phases) || phases.length === 0 || phases.length > MAX_PHASES) {
|
|
174
|
+
throw new ShapeError(`bundle.phases: must list 1 to ${MAX_PHASES} phases`);
|
|
175
|
+
}
|
|
176
|
+
const names = new Set();
|
|
177
|
+
phases.forEach((p, i) => {
|
|
178
|
+
const where = `bundle.phases[${i}]`;
|
|
179
|
+
if (!isPlainObject(p))
|
|
180
|
+
throw new ShapeError(`${where}: must be an object`);
|
|
181
|
+
exactKeys(p, ["order", "name", "prompt", "runner"], where);
|
|
182
|
+
if (p.order !== i)
|
|
183
|
+
throw new ShapeError(`${where}.order: must be ${i}`);
|
|
184
|
+
const name = str(p.name, PHASE_NAME_RE, `${where}.name`);
|
|
185
|
+
if (names.has(name))
|
|
186
|
+
throw new ShapeError(`${where}.name: duplicate phase name`);
|
|
187
|
+
names.add(name);
|
|
188
|
+
boundedText(p.prompt, MAX_PROMPT_BYTES, `${where}.prompt`);
|
|
189
|
+
checkRunner(p.runner, `${where}.runner`);
|
|
190
|
+
});
|
|
191
|
+
if (typeof v.signature !== "string")
|
|
192
|
+
throw new ShapeError("bundle.signature: must be a string");
|
|
193
|
+
}
|
|
194
|
+
/** The exact bytes the signature covers: canonical JSON of every field but `signature`. */
|
|
195
|
+
export function signedBytes(bundle) {
|
|
196
|
+
const { signature: _signature, ...rest } = bundle;
|
|
197
|
+
return canonicalJson(rest);
|
|
198
|
+
}
|
|
199
|
+
function locateKey(keyOrJwks, kid) {
|
|
200
|
+
if ("keys" in keyOrJwks && Array.isArray(keyOrJwks.keys)) {
|
|
201
|
+
const matches = keyOrJwks.keys.filter((k) => k.kid === kid);
|
|
202
|
+
// An ambiguous kid is not a key a signature can be attributed to.
|
|
203
|
+
return matches.length === 1 ? matches[0] : null;
|
|
204
|
+
}
|
|
205
|
+
return keyOrJwks.kid === kid ? keyOrJwks : null;
|
|
206
|
+
}
|
|
207
|
+
function refuse(reason, detail) {
|
|
208
|
+
return { valid: false, reason, detail };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Verify one served bundle against the published key(s). Never throws: any unexpected failure
|
|
212
|
+
* is a refusal, never a pass.
|
|
213
|
+
*/
|
|
214
|
+
export function verifyServedPhaseBundle(served, keyOrJwks, expect, options = {}) {
|
|
215
|
+
try {
|
|
216
|
+
return verifyInner(served, keyOrJwks, expect, options);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return refuse("malformed", `verifier error: ${err instanceof Error ? err.message : String(err)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function verifyInner(served, keyOrJwks, expect, options) {
|
|
223
|
+
if (typeof served !== "string")
|
|
224
|
+
return refuse("malformed", "bundle must be served as text");
|
|
225
|
+
if (utf8Bytes(served) > MAX_BUNDLE_BYTES) {
|
|
226
|
+
return refuse("too_large", `bundle is larger than ${MAX_BUNDLE_BYTES} bytes`);
|
|
227
|
+
}
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(served);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return refuse("malformed", "bundle is not valid JSON");
|
|
234
|
+
}
|
|
235
|
+
if (!isPlainObject(parsed))
|
|
236
|
+
return refuse("malformed", "bundle is not a JSON object");
|
|
237
|
+
if (parsed.typ !== SERVED_PHASE_BUNDLE_TYP) {
|
|
238
|
+
return refuse("wrong_typ", `typ is ${JSON.stringify(parsed.typ ?? null)}`);
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
assertBundleShape(parsed);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
// assertBundleShape throws only ShapeError.
|
|
245
|
+
return refuse("schema", err.message);
|
|
246
|
+
}
|
|
247
|
+
const bundle = parsed;
|
|
248
|
+
// Key.
|
|
249
|
+
const kid = bundle.signing_key_id;
|
|
250
|
+
const jwk = locateKey(keyOrJwks, kid);
|
|
251
|
+
if (!jwk)
|
|
252
|
+
return refuse("unknown_key", `no single published key has kid ${kid}`);
|
|
253
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string") {
|
|
254
|
+
return refuse("unusable_key", `key ${kid} is not an Ed25519 OKP key`);
|
|
255
|
+
}
|
|
256
|
+
const status = jwk.mnemom_ext?.status;
|
|
257
|
+
if (!USABLE_KEY_STATUSES.has(status)) {
|
|
258
|
+
return refuse("unusable_key", `key ${kid} has status ${JSON.stringify(status)}`);
|
|
259
|
+
}
|
|
260
|
+
if (status === "retired") {
|
|
261
|
+
const retiredAt = Date.parse(jwk.mnemom_ext?.retired_at ?? "");
|
|
262
|
+
if (!Number.isFinite(retiredAt) || Date.parse(bundle.issued_at) >= retiredAt) {
|
|
263
|
+
return refuse("retired_key", `key ${kid} was retired before this bundle was issued`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let publicKey;
|
|
267
|
+
try {
|
|
268
|
+
publicKey = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, format: "jwk" });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return refuse("unusable_key", `key ${kid} could not be imported`);
|
|
272
|
+
}
|
|
273
|
+
// Signature over canonical JSON of every field but `signature`.
|
|
274
|
+
if (!BASE64URL_RE.test(bundle.signature))
|
|
275
|
+
return refuse("bad_signature", "signature is not base64url");
|
|
276
|
+
const sig = Buffer.from(bundle.signature, "base64url");
|
|
277
|
+
if (sig.length !== ED25519_SIGNATURE_BYTES) {
|
|
278
|
+
return refuse("bad_signature", `signature is ${sig.length} bytes, not ${ED25519_SIGNATURE_BYTES}`);
|
|
279
|
+
}
|
|
280
|
+
const message = Buffer.from(signedBytes(parsed), "utf8");
|
|
281
|
+
if (!cryptoVerify(null, message, publicKey, sig)) {
|
|
282
|
+
return refuse("bad_signature", `signature does not verify under key ${kid}`);
|
|
283
|
+
}
|
|
284
|
+
// Signed, but internally inconsistent: a runner may cache code by its hash.
|
|
285
|
+
for (const phase of bundle.phases) {
|
|
286
|
+
if (phase.runner.kind === "code" && sha256Hex(phase.runner.source) !== phase.runner.sha256) {
|
|
287
|
+
return refuse("code_digest_mismatch", `phase ${phase.order} code sha256 does not match its source`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Time.
|
|
291
|
+
const now = options.now ? options.now() : Date.now();
|
|
292
|
+
const skewMs = (options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
|
|
293
|
+
if (Date.parse(bundle.issued_at) > now + skewMs) {
|
|
294
|
+
return refuse("not_yet_valid", `issued_at ${bundle.issued_at} is in the future`);
|
|
295
|
+
}
|
|
296
|
+
if (Date.parse(bundle.expires_at) + skewMs < now) {
|
|
297
|
+
return refuse("expired", `expired at ${bundle.expires_at}`);
|
|
298
|
+
}
|
|
299
|
+
// Binding to the claim this runner holds.
|
|
300
|
+
const actual = {
|
|
301
|
+
org_id: bundle.org_id,
|
|
302
|
+
session_id: bundle.claim.session_id,
|
|
303
|
+
epoch: bundle.claim.epoch,
|
|
304
|
+
executor_id: bundle.claim.executor_id,
|
|
305
|
+
contract_sha256: bundle.contract.sha256,
|
|
306
|
+
contract_version: bundle.contract.version,
|
|
307
|
+
};
|
|
308
|
+
// An untyped caller may pass no expectation at all: that refuses as a binding miss, the same
|
|
309
|
+
// reason the API's verifier gives, rather than crashing into the catch-all.
|
|
310
|
+
if (!isPlainObject(expect)) {
|
|
311
|
+
return refuse("binding_mismatch", "no expected claim binding was given");
|
|
312
|
+
}
|
|
313
|
+
for (const field of Object.keys(actual)) {
|
|
314
|
+
if (expect[field] !== actual[field]) {
|
|
315
|
+
return refuse("binding_mismatch", `${field} does not match the claim being checked`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { valid: true, bundle, digest: sha256Hex(signedBytes(parsed)), key_id: kid };
|
|
319
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sealed contract on every served-phase model request (Doors W-L13, W3.11).
|
|
3
|
+
*
|
|
4
|
+
* Every model request of every served phase carries `x-mnemom-contract` with the job's sealed
|
|
5
|
+
* version and no other. The runner's own proxy states it: the M1 seat proxy strips client
|
|
6
|
+
* `x-mnemom-*` headers, and a phase must never choose its own contract.
|
|
7
|
+
*
|
|
8
|
+
* The value is the contract file's exact bytes (`.mnemom/contracts/<contract_sha256>.json`: the
|
|
9
|
+
* canonical JSON of the hash-form contract, no trailing newline, so sha256 of the bytes IS the
|
|
10
|
+
* contract sha256) as base64url, which the gateway's `parseContractHeader` accepts. The runner
|
|
11
|
+
* binds those bytes to the claim before any request: their sha256 must equal the claim's
|
|
12
|
+
* `contract_sha256`, which the verified bundle also cites. A mismatch is a refusal, never a
|
|
13
|
+
* fallback to another copy.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CONTRACT_HEADER = "x-mnemom-contract";
|
|
16
|
+
/** A card agent's pointer to a contract; a served phase never sends one. */
|
|
17
|
+
export declare const CONTRACT_REF_HEADER = "x-mnemom-contract-ref";
|
|
18
|
+
/** The gateway's CONTRACT_JSON_MAX, through the CLI's contract-hash helper. */
|
|
19
|
+
export declare const CONTRACT_FILE_MAX_BYTES = 8192;
|
|
20
|
+
export type SealedContractHeader = {
|
|
21
|
+
ok: true;
|
|
22
|
+
value: string;
|
|
23
|
+
contract_sha256: string;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: "bad_sha" | "too_large" | "sha_mismatch" | "not_a_contract";
|
|
27
|
+
detail: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The header value for one job, or why there is none. `bytes` is the contract file as stored
|
|
31
|
+
* or as the job read returned it. Nothing is normalised or re-serialised: the bytes that hash to
|
|
32
|
+
* the seal are the bytes sent.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sealedContractHeader(bytes: Uint8Array, contractSha256: string): SealedContractHeader;
|
|
35
|
+
/**
|
|
36
|
+
* One outbound request's headers with the sealed contract set. Any contract or contract-ref
|
|
37
|
+
* header already present (from the phase, its tools or a stale caller) is dropped, whatever its
|
|
38
|
+
* case, so exactly one `x-mnemom-contract` leaves the runner.
|
|
39
|
+
*/
|
|
40
|
+
export declare function applySealedContract(headers: Record<string, string | string[] | undefined>, value: string): Record<string, string | string[] | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* The sha256 an `x-mnemom-contract` value names, when it is the base64url of a contract file
|
|
43
|
+
* (what this runner sends); null otherwise. Used by the runner's own self-check and by the
|
|
44
|
+
* `door2-path` header step to assert no request carried another seal.
|
|
45
|
+
*/
|
|
46
|
+
export declare function contractHeaderSha256(value: string): string | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sealed contract on every served-phase model request (Doors W-L13, W3.11).
|
|
3
|
+
*
|
|
4
|
+
* Every model request of every served phase carries `x-mnemom-contract` with the job's sealed
|
|
5
|
+
* version and no other. The runner's own proxy states it: the M1 seat proxy strips client
|
|
6
|
+
* `x-mnemom-*` headers, and a phase must never choose its own contract.
|
|
7
|
+
*
|
|
8
|
+
* The value is the contract file's exact bytes (`.mnemom/contracts/<contract_sha256>.json`: the
|
|
9
|
+
* canonical JSON of the hash-form contract, no trailing newline, so sha256 of the bytes IS the
|
|
10
|
+
* contract sha256) as base64url, which the gateway's `parseContractHeader` accepts. The runner
|
|
11
|
+
* binds those bytes to the claim before any request: their sha256 must equal the claim's
|
|
12
|
+
* `contract_sha256`, which the verified bundle also cites. A mismatch is a refusal, never a
|
|
13
|
+
* fallback to another copy.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { CONTRACT_JSON_MAX } from "../contract-hash.js";
|
|
17
|
+
export const CONTRACT_HEADER = "x-mnemom-contract";
|
|
18
|
+
/** A card agent's pointer to a contract; a served phase never sends one. */
|
|
19
|
+
export const CONTRACT_REF_HEADER = "x-mnemom-contract-ref";
|
|
20
|
+
/** The gateway's CONTRACT_JSON_MAX, through the CLI's contract-hash helper. */
|
|
21
|
+
export const CONTRACT_FILE_MAX_BYTES = CONTRACT_JSON_MAX;
|
|
22
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
23
|
+
/**
|
|
24
|
+
* The header value for one job, or why there is none. `bytes` is the contract file as stored
|
|
25
|
+
* or as the job read returned it. Nothing is normalised or re-serialised: the bytes that hash to
|
|
26
|
+
* the seal are the bytes sent.
|
|
27
|
+
*/
|
|
28
|
+
export function sealedContractHeader(bytes, contractSha256) {
|
|
29
|
+
if (!SHA256_RE.test(contractSha256)) {
|
|
30
|
+
return { ok: false, reason: "bad_sha", detail: "contract_sha256 must be 64 lowercase hex" };
|
|
31
|
+
}
|
|
32
|
+
if (bytes.byteLength > CONTRACT_FILE_MAX_BYTES) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: "too_large",
|
|
36
|
+
detail: `contract is larger than ${CONTRACT_FILE_MAX_BYTES} bytes`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
40
|
+
if (actual !== contractSha256) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: "sha_mismatch",
|
|
44
|
+
detail: "contract bytes do not hash to the sealed contract_sha256",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { ok: false, reason: "not_a_contract", detail: "contract bytes are not JSON" };
|
|
53
|
+
}
|
|
54
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
55
|
+
return { ok: false, reason: "not_a_contract", detail: "contract is not a JSON object" };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
value: Buffer.from(bytes).toString("base64url"),
|
|
60
|
+
contract_sha256: contractSha256,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One outbound request's headers with the sealed contract set. Any contract or contract-ref
|
|
65
|
+
* header already present (from the phase, its tools or a stale caller) is dropped, whatever its
|
|
66
|
+
* case, so exactly one `x-mnemom-contract` leaves the runner.
|
|
67
|
+
*/
|
|
68
|
+
export function applySealedContract(headers, value) {
|
|
69
|
+
const out = Object.fromEntries(Object.entries(headers).filter(([name]) => {
|
|
70
|
+
const lower = name.toLowerCase();
|
|
71
|
+
return lower !== CONTRACT_HEADER && lower !== CONTRACT_REF_HEADER;
|
|
72
|
+
}));
|
|
73
|
+
out[CONTRACT_HEADER] = value;
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The sha256 an `x-mnemom-contract` value names, when it is the base64url of a contract file
|
|
78
|
+
* (what this runner sends); null otherwise. Used by the runner's own self-check and by the
|
|
79
|
+
* `door2-path` header step to assert no request carried another seal.
|
|
80
|
+
*/
|
|
81
|
+
export function contractHeaderSha256(value) {
|
|
82
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
83
|
+
return null;
|
|
84
|
+
return createHash("sha256").update(Buffer.from(value, "base64url")).digest("hex");
|
|
85
|
+
}
|