yam-flow 2.5.0 → 2.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to yam-flow are documented here.
4
4
 
5
+ ## v2.5.1 - 2026-07-31
6
+
7
+ ### Added
8
+
9
+ - Added bounded stdin readers with a 1 MiB hook limit and a 4 MiB general CLI limit. Malformed or oversized hook payloads now return a valid fail-open response without echoing or acting on rejected input.
10
+ - Added a final read-only `yam doctor --json` verification to yam updates and automatic rollbacks. The updater accepts success only when the command exits cleanly and returns the expected healthy Doctor contract.
11
+ - Added a release-time secret guard over the actual npm dry-run packlist. It scans regular UTF-8 text files for high-signal private-key and provider credential patterns while reporting only pattern, path, and line metadata.
12
+
13
+ ### Improved
14
+
15
+ - Install and Ueye integrity manifests now use locale-independent ordinal ordering. Existing yam 2.5.0-and-earlier receipts retain compatibility with their stored legacy order while new digests are deterministic across locales.
16
+
17
+ ### Security
18
+
19
+ - Rejected stdin payloads are byte-bounded, are not included in diagnostics, and cannot supply hook context or trigger workspace mutation.
20
+ - `verify:self`, `prepack`, `prepublishOnly`, and `release:check` now inherit the npm packlist secret guard; the inner pack inspection disables lifecycle scripts to avoid recursive execution.
21
+ - Updater Doctor output recorded in receipts remains redacted and bounded, and an unhealthy rollback is not reported as an automatic recovery.
22
+
23
+ ### Verification
24
+
25
+ - TypeScript typecheck and build
26
+ - Bounded hook latency/read-only smoke and packaged CLI smoke
27
+ - Locale-independent install/Ueye manifest regression smokes
28
+ - External update/rollback Doctor smoke
29
+ - Npm packlist secret-pattern and redaction smoke
30
+
5
31
  ## v2.5.0 - 2026-07-30
6
32
 
7
33
  ### Added
package/README.md CHANGED
@@ -99,6 +99,8 @@ yam hook status --global
99
99
 
100
100
  The profile installs `UserPromptSubmit` and `Stop` handlers. At `Stop`, yam checks the latest assistant message with the same read-only Study Note Guard used by `yam study-note check`. If changed artifacts lack the required role, execution point, before/after, expected behavior, syntax/structure, verification, limits, or relevant architecture hygiene, Codex receives one correction prompt. A second failed check warns but does not loop forever. If Git change scope is unavailable, the guard reports partial truth and keeps a manual-inspection warning visible instead of claiming the project is clean. The hook never writes the Study Note or runs verification for the agent.
101
101
 
102
+ Hook stdin is limited to 1 MiB. Malformed JSON or an oversized payload produces a valid fail-open response without echoing the rejected content, deriving hook context from it, or mutating the workspace. Other CLI commands that read piped text use a 4 MiB limit and fail closed when it is exceeded.
103
+
102
104
  `yam hook status` reports stale or missing command targets as `broken` and exits nonzero. Run `yam hook enable <profile> --global` again to create a timestamped backup, preserve unrelated hooks, and migrate that profile to the current installed path and event coverage. Restart Codex or open a new task after changing hooks.
103
105
 
104
106
  ## Install
@@ -120,7 +122,7 @@ yam install --replace-user-skill quick
120
122
 
121
123
  Repeat `--replace-user-skill <name>` for each reviewed conflict. Unproven legacy, retired, and Codex mirror directories are preserved rather than deleted by name.
122
124
 
123
- A successful install writes `~/.agents/skills/.yam-flow-install-receipt.json`. The receipt records the package version, source identity, install timestamp, destination, managed skill inventory, and per-file hashes. `yam status` independently recomputes source and installed hashes, so a stale package install, local file change, unexpected file, or modified receipt is reported as drift.
125
+ A successful install writes `~/.agents/skills/.yam-flow-install-receipt.json`. The receipt records the package version, source identity, install timestamp, destination, managed skill inventory, and per-file hashes. Integrity paths and digests use locale-independent ordinal ordering; receipts written by yam 2.5.0 and earlier are also checked against their stored legacy order during upgrade. `yam status` independently recomputes source and installed hashes, so a stale package install, local file change, unexpected file, or modified receipt is reported as drift.
124
126
 
125
127
  The receipt is local integrity provenance, not a cryptographic signature or a security boundary against another process running as the same OS user. An actor that can rewrite both the receipt and every recorded file can forge local ownership evidence.
126
128
 
@@ -148,7 +150,7 @@ yam update apply --component yam
148
150
  yam update apply --all --json
149
151
  ```
150
152
 
151
- `--all` processes Scrapling, then Insane Search, then yam so the running updater replaces itself last. It stops at the first failed or manual-only component. Each attempt writes a component receipt under `~/.local/state/yam/update-receipts/` with the old/new version, source revision, checks, side effects, outcome, and rollback guidance. A lock prevents concurrent apply runs.
153
+ `--all` processes Scrapling, then Insane Search, then yam so the running updater replaces itself last. It stops at the first failed or manual-only component. A yam update is successful only after the installed version and status checks plus a final read-only `yam doctor --json` return the expected healthy contract. Doctor failure triggers the existing rollback path, and rollback is considered automatic only when its own version, status, and Doctor checks pass. Each attempt writes a component receipt under `~/.local/state/yam/update-receipts/` with the old/new version, source revision, checks, side effects, outcome, and rollback guidance. A lock prevents concurrent apply runs.
152
154
 
153
155
  Scrapling updates install an exact stable PyPI version into a new versioned virtual environment. `pip check`, ordinary HTTP extraction, and headless browser extraction must pass before the executable symlink changes atomically; the previous environment is retained. An existing environment must have a matching `.yam-scrapling-install.json` ownership marker before yam will replace its link. Insane Search uses only the official `codex plugin` commands. yam never edits `.codex/plugins/cache`, never removes the plugin first, and returns `manual_plugin_update_required` when the installed Codex CLI cannot update it safely in place.
154
156
 
@@ -255,6 +257,8 @@ yam status
255
257
 
256
258
  After `yam status` reports all skills and the install receipt as `ok`, restart Codex so the refreshed skills reload.
257
259
 
260
+ Before packaging or publishing, `verify:self` inspects the exact file list returned by `npm pack --dry-run --json --ignore-scripts`. The release gate scans regular UTF-8 text files up to 2 MiB for high-signal private-key, npm, GitHub, OpenAI, Anthropic, and AWS credential patterns. Findings expose only the pattern id, package-relative path, and line number; matched text is never printed. Binary, symlink, non-regular, unreadable, outside-root, and oversized entries are reported as skipped rather than silently represented as scanned.
261
+
258
262
  Publishing still requires confirming the final npm package name and account access.
259
263
 
260
264
  ## Trust Kernel
package/ROADMAP.md CHANGED
@@ -172,7 +172,7 @@ Tasks:
172
172
 
173
173
  - Add `.yam/memory/records/*.json` storage. Done.
174
174
  - Add `yam memory init/add/list/summary/resolve`. Done.
175
- - Add lightweight secret-pattern blocking. Done.
175
+ - Add lightweight secret-pattern blocking, including an actual npm dry-run packlist release guard with redacted findings. Done.
176
176
  - Teach context reuse to prefer `summary.md`, not full record reads. Done.
177
177
  - Keep memory opt-in only. Done.
178
178
 
@@ -308,6 +308,7 @@ Tasks:
308
308
  - Keep `yam-lite` advisory-only through `UserPromptSubmit`. Done.
309
309
  - Add Study Note `UserPromptSubmit` reminder plus one-pass `Stop` completion gate. Done.
310
310
  - Detect broken hook targets and migrate stale paths without replacing unrelated hooks. Done.
311
+ - Bound hook/general stdin and fail open safely for rejected hook payloads. Done.
311
312
  - Add `references/tool-trust-layer.md`. Done.
312
313
  - Add read-only `yam tools doctor`. Done.
313
314
  - Add `yam proof` summary command. Done.
@@ -318,6 +319,8 @@ Tasks:
318
319
  - Add shallow `.sql` destructive keyword scan. Done.
319
320
  - Add `yam proof write`. Done.
320
321
  - Prepare npm/npx package metadata and dry-run packaging. Done.
322
+ - Make integrity-critical install and Ueye manifest ordering locale-independent. Done.
323
+ - Require a final read-only Doctor contract for yam update and rollback success. Done.
321
324
  - Add small route-scoped trust kernel: truth caps, fake/real policy, runtime truth matrix, and completion proof object. Done.
322
325
 
323
326
  ### 16. Deferred Triggers
package/dist/bin/yam.js CHANGED
@@ -12,6 +12,7 @@ import { TRUTH_STATUSES, buildLoopReport, buildMediaGenerationProof, buildMissio
12
12
  import { archiveUeyeRevision, inspectImageFile, upsertUeyeAsset, verifyUeyeAssetManifest, verifyUeyeRevisionHistory } from '../lib/ueye-artifacts.js';
13
13
  import { inspectSkillInstallation, installSkillSetTransactional, uninstallSkillSetSafely } from '../lib/skill-installation.js';
14
14
  import { EXTERNAL_UPDATE_COMPONENTS, applyExternalUpdates, checkExternalUpdates } from '../lib/external-updates.js';
15
+ import { BoundedInputError, GENERAL_STDIN_MAX_BYTES, HOOK_STDIN_MAX_BYTES, readBoundedStdinJson, readBoundedStdinText } from '../lib/bounded-input.js';
15
16
  function errorMessage(error) {
16
17
  return error instanceof Error ? error.message : String(error);
17
18
  }
@@ -2008,7 +2009,20 @@ async function hookRun(args = []) {
2008
2009
  console.log(JSON.stringify({ continue: true }));
2009
2010
  return;
2010
2011
  }
2011
- const input = await readStdinJson();
2012
+ let input;
2013
+ try {
2014
+ input = await readStdinJson();
2015
+ }
2016
+ catch (error) {
2017
+ if (!(error instanceof BoundedInputError))
2018
+ throw error;
2019
+ const code = error.code === 'invalid_json' ? 'invalid_hook_json' : error.code;
2020
+ console.log(JSON.stringify({
2021
+ continue: true,
2022
+ systemMessage: `yam-${profile} hook input rejected: ${code}; continuing without using or echoing the payload.`
2023
+ }));
2024
+ return;
2025
+ }
2012
2026
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
2013
2027
  const cwd = String(input?.cwd || process.cwd());
2014
2028
  if (profile === 'study-note' && event === 'Stop') {
@@ -2029,18 +2043,7 @@ async function hookRun(args = []) {
2029
2043
  console.log(JSON.stringify(output));
2030
2044
  }
2031
2045
  async function readStdinJson() {
2032
- const chunks = [];
2033
- for await (const chunk of process.stdin)
2034
- chunks.push(Buffer.from(chunk));
2035
- const text = Buffer.concat(chunks).toString('utf8').trim();
2036
- if (!text)
2037
- return {};
2038
- try {
2039
- return JSON.parse(text);
2040
- }
2041
- catch {
2042
- return {};
2043
- }
2046
+ return readBoundedStdinJson(process.stdin, HOOK_STDIN_MAX_BYTES);
2044
2047
  }
2045
2048
  function extractPrompt(input = {}) {
2046
2049
  const candidates = [
@@ -2671,10 +2674,7 @@ function redactSensitiveText(text = '') {
2671
2674
  async function readStdinTextIfAvailable() {
2672
2675
  if (process.stdin.isTTY)
2673
2676
  return '';
2674
- const chunks = [];
2675
- for await (const chunk of process.stdin)
2676
- chunks.push(Buffer.from(chunk));
2677
- return Buffer.concat(chunks).toString('utf8').trim();
2677
+ return (await readBoundedStdinText(process.stdin, GENERAL_STDIN_MAX_BYTES)).trim();
2678
2678
  }
2679
2679
  async function loop(args = []) {
2680
2680
  const subcommand = args[0] || 'help';
@@ -0,0 +1,12 @@
1
+ export declare const HOOK_STDIN_MAX_BYTES: number;
2
+ export declare const GENERAL_STDIN_MAX_BYTES: number;
3
+ export type BoundedInputErrorCode = 'input_too_large' | 'invalid_json';
4
+ export declare class BoundedInputError extends Error {
5
+ readonly code: BoundedInputErrorCode;
6
+ readonly maxBytes?: number;
7
+ constructor(code: BoundedInputErrorCode, options?: {
8
+ maxBytes?: number;
9
+ });
10
+ }
11
+ export declare function readBoundedStdinText(input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>, maxBytes: number): Promise<string>;
12
+ export declare function readBoundedStdinJson(input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>, maxBytes: number): Promise<Record<string, unknown>>;
@@ -0,0 +1,48 @@
1
+ export const HOOK_STDIN_MAX_BYTES = 1024 * 1024;
2
+ export const GENERAL_STDIN_MAX_BYTES = 4 * 1024 * 1024;
3
+ export class BoundedInputError extends Error {
4
+ code;
5
+ maxBytes;
6
+ constructor(code, options = {}) {
7
+ const detail = code === 'input_too_large' && options.maxBytes
8
+ ? `stdin input exceeds the ${options.maxBytes}-byte limit`
9
+ : 'stdin input is not a valid JSON object';
10
+ super(`[${code}] ${detail}`);
11
+ this.name = 'BoundedInputError';
12
+ this.code = code;
13
+ this.maxBytes = options.maxBytes;
14
+ }
15
+ }
16
+ export async function readBoundedStdinText(input, maxBytes) {
17
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
18
+ throw new Error('stdin byte limit must be a positive safe integer');
19
+ }
20
+ const chunks = [];
21
+ let totalBytes = 0;
22
+ for await (const chunk of input) {
23
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
24
+ totalBytes += bytes.byteLength;
25
+ if (totalBytes > maxBytes) {
26
+ throw new BoundedInputError('input_too_large', { maxBytes });
27
+ }
28
+ chunks.push(bytes);
29
+ }
30
+ return Buffer.concat(chunks, totalBytes).toString('utf8');
31
+ }
32
+ export async function readBoundedStdinJson(input, maxBytes) {
33
+ const text = (await readBoundedStdinText(input, maxBytes)).trim();
34
+ if (!text)
35
+ return {};
36
+ try {
37
+ const value = JSON.parse(text);
38
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
39
+ throw new BoundedInputError('invalid_json');
40
+ }
41
+ return value;
42
+ }
43
+ catch (error) {
44
+ if (error instanceof BoundedInputError)
45
+ throw error;
46
+ throw new BoundedInputError('invalid_json');
47
+ }
48
+ }
@@ -354,6 +354,13 @@ async function applyYam(currentYamVersion, deps) {
354
354
  break;
355
355
  }
356
356
  }
357
+ if (!postFailure) {
358
+ const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
359
+ const validation = doctorValidation('yam_finalize_doctor', doctor);
360
+ checks.push(validation.check);
361
+ if (!validation.passed)
362
+ postFailure = `yam_finalize_doctor failed: ${validation.error}`;
363
+ }
357
364
  if (postFailure) {
358
365
  const rollback = await rollbackYam(check.installed_version, deps, checks);
359
366
  return persistReceipt(baseReceipt(check, 'failed', checks, {
@@ -392,10 +399,17 @@ async function rollbackYam(previousVersion, deps, checks) {
392
399
  const install = await deps.run('yam', ['install'], { env: deps.env, timeout: 180000 });
393
400
  const version = await deps.run('yam', ['version'], { env: deps.env, timeout: 30000 });
394
401
  const status = await deps.run('yam', ['status'], { env: deps.env, timeout: 120000 });
402
+ const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
403
+ const doctorResult = doctorValidation('automatic_yam_rollback_doctor', doctor);
395
404
  checks.push(commandCheck('automatic_yam_rollback_skills', install));
396
405
  checks.push(commandCheck('automatic_yam_rollback_version', version));
397
406
  checks.push(commandCheck('automatic_yam_rollback_status', status));
398
- return install.ok && version.ok && extractVersion(version.stdout) === previousVersion && status.ok;
407
+ checks.push(doctorResult.check);
408
+ return (install.ok
409
+ && version.ok
410
+ && extractVersion(version.stdout) === previousVersion
411
+ && status.ok
412
+ && doctorResult.passed);
399
413
  }
400
414
  async function applyScrapling(deps) {
401
415
  const check = await checkScrapling(deps);
@@ -820,7 +834,7 @@ function yamRollback(previousVersion) {
820
834
  automatic: false,
821
835
  previous_target: previousVersion,
822
836
  guidance: previousVersion
823
- ? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, and \`yam status\`.`
837
+ ? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, \`yam status\`, and \`yam doctor --json\`.`
824
838
  : 'Inspect the global npm installation before attempting a manual rollback.'
825
839
  };
826
840
  }
@@ -858,6 +872,57 @@ function commandCheck(id, result) {
858
872
  note: commandNote(result)
859
873
  };
860
874
  }
875
+ function doctorValidation(id, result) {
876
+ if (!result.ok) {
877
+ const note = boundedNote(`command failed: ${commandNote(result)}`);
878
+ return {
879
+ passed: false,
880
+ error: note,
881
+ check: { id, status: 'failed', note }
882
+ };
883
+ }
884
+ let report;
885
+ try {
886
+ const parsed = JSON.parse(String(result.stdout || ''));
887
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
888
+ throw new Error('not an object');
889
+ }
890
+ report = parsed;
891
+ }
892
+ catch {
893
+ const note = 'yam doctor returned invalid JSON';
894
+ return {
895
+ passed: false,
896
+ error: note,
897
+ check: { id, status: 'failed', note }
898
+ };
899
+ }
900
+ if (report.schema !== 'yam.doctor.v1') {
901
+ const note = 'yam doctor returned an unexpected schema';
902
+ return {
903
+ passed: false,
904
+ error: note,
905
+ check: { id, status: 'failed', note }
906
+ };
907
+ }
908
+ if (report.ok !== true) {
909
+ const note = 'yam.doctor.v1 reported ok=false';
910
+ return {
911
+ passed: false,
912
+ error: note,
913
+ check: { id, status: 'failed', note }
914
+ };
915
+ }
916
+ const note = 'yam.doctor.v1 reported ok=true';
917
+ return {
918
+ passed: true,
919
+ error: '',
920
+ check: { id, status: 'passed', note }
921
+ };
922
+ }
923
+ function boundedNote(text) {
924
+ return redactSensitiveText(String(text || '')).slice(0, 800);
925
+ }
861
926
  function commandNote(result) {
862
927
  const text = redactSensitiveText([result.stdout, result.stderr].filter(Boolean).join('\n'));
863
928
  const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { compareStableText } from './stable-order.js';
4
5
  export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
5
6
  export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
6
7
  export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
@@ -70,7 +71,7 @@ async function collectTreeManifest(root, prefix = '') {
70
71
  const files = [];
71
72
  async function walk(current, relative) {
72
73
  const entries = await fsp.readdir(current, { withFileTypes: true });
73
- entries.sort((left, right) => left.name.localeCompare(right.name));
74
+ entries.sort((left, right) => compareStableText(left.name, right.name));
74
75
  for (const entry of entries) {
75
76
  const absolute = path.join(current, entry.name);
76
77
  const nextRelative = relative ? path.join(relative, entry.name) : entry.name;
@@ -86,7 +87,7 @@ async function collectTreeManifest(root, prefix = '') {
86
87
  }
87
88
  }
88
89
  await walk(root, '');
89
- return files.sort((left, right) => left.path.localeCompare(right.path));
90
+ return files.sort((left, right) => compareStableText(left.path, right.path));
90
91
  }
91
92
  async function expectedInstallManifest(sourceRoot, skills) {
92
93
  const referencesRoot = path.join(sourceRoot, 'references');
@@ -112,7 +113,7 @@ async function expectedInstallManifest(sourceRoot, skills) {
112
113
  });
113
114
  }
114
115
  }
115
- return files.sort((left, right) => left.path.localeCompare(right.path));
116
+ return files.sort((left, right) => compareStableText(left.path, right.path));
116
117
  }
117
118
  async function copyTree(source, target) {
118
119
  const stat = await fsp.lstat(source);
@@ -137,8 +138,11 @@ async function copyTree(source, target) {
137
138
  }
138
139
  }
139
140
  function manifestDigest(files) {
141
+ return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
142
+ }
143
+ function digestManifestFiles(files) {
140
144
  const digest = createHash('sha256');
141
- for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
145
+ for (const file of files) {
142
146
  digest.update(file.path);
143
147
  digest.update('\0');
144
148
  digest.update(String(file.bytes));
@@ -148,6 +152,20 @@ function manifestDigest(files) {
148
152
  }
149
153
  return digest.digest('hex');
150
154
  }
155
+ function allowsLegacyManifestOrder(version) {
156
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version || ''));
157
+ if (!match)
158
+ return false;
159
+ const major = Number(match[1]);
160
+ const minor = Number(match[2]);
161
+ const patch = Number(match[3]);
162
+ return major < 2 || (major === 2 && (minor < 5 || (minor === 5 && patch === 0)));
163
+ }
164
+ function receiptManifestDigestMatches(files, digest, version) {
165
+ if (digest === manifestDigest(files))
166
+ return true;
167
+ return allowsLegacyManifestOrder(version) && digest === digestManifestFiles(files);
168
+ }
151
169
  function manifestIssues(expected, actual, label) {
152
170
  const issues = [];
153
171
  const expectedByPath = new Map(expected.map((file) => [file.path, file]));
@@ -236,7 +254,7 @@ function ownershipReceiptIssues(receipt, packageName, destination) {
236
254
  if (value.integrity?.file_count !== files.length) {
237
255
  issues.push('install receipt file count does not match its manifest');
238
256
  }
239
- if (value.integrity?.source_digest !== manifestDigest(files)) {
257
+ if (!receiptManifestDigestMatches(files, value.integrity?.source_digest, value.package?.version)) {
240
258
  issues.push('install receipt source digest does not match its manifest');
241
259
  }
242
260
  if (Array.isArray(value.skills)) {
@@ -0,0 +1 @@
1
+ export declare function compareStableText(left: string, right: string): number;
@@ -0,0 +1,5 @@
1
+ export function compareStableText(left, right) {
2
+ if (left === right)
3
+ return 0;
4
+ return left < right ? -1 : 1;
5
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { constants as fsConstants } from 'node:fs';
3
3
  import fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import { compareStableText } from './stable-order.js';
5
6
  export async function inspectImageFile(file) {
6
7
  const absolute = path.resolve(file);
7
8
  const buffer = await fsp.readFile(absolute);
@@ -46,7 +47,7 @@ export async function upsertUeyeAsset(input) {
46
47
  manifest.assets[existingIndex] = entry;
47
48
  else
48
49
  manifest.assets.push(entry);
49
- manifest.assets.sort((left, right) => left.id.localeCompare(right.id));
50
+ manifest.assets.sort((left, right) => compareStableText(left.id, right.id));
50
51
  manifest.updated_at = new Date().toISOString();
51
52
  await writeJsonAtomic(manifestPath, manifest);
52
53
  const warnings = assetWarnings(entry);
@@ -152,7 +153,7 @@ export async function archiveUeyeRevision(input) {
152
153
  else {
153
154
  history.revisions.push(entry);
154
155
  }
155
- history.revisions.sort((left, right) => left.round - right.round || left.artifact_id.localeCompare(right.artifact_id));
156
+ history.revisions.sort((left, right) => left.round - right.round || compareStableText(left.artifact_id, right.artifact_id));
156
157
  history.updated_at = new Date().toISOString();
157
158
  await writeJsonAtomic(manifestPath, history);
158
159
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yam-flow",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Progressive proof-first agent flow: start fast, deepen deliberately, stay honest by design.",
5
5
  "type": "module",
6
6
  "author": "0kim0bos",
@@ -52,7 +52,7 @@
52
52
  "doctor": "node ./dist/bin/yam.js doctor",
53
53
  "list": "node ./dist/bin/yam.js list",
54
54
  "examples": "node ./dist/bin/yam.js examples",
55
- "verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node --check ./dist/lib/external-updates.js && npm run hook-latency-smoke && node ./scripts/external-updates-smoke.mjs && node ./scripts/install-transaction-smoke.mjs && node ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
55
+ "verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node --check ./dist/lib/external-updates.js && npm run hook-latency-smoke && node ./scripts/external-updates-smoke.mjs && node ./scripts/install-transaction-smoke.mjs && npm run package-secrets:check && npm run package-secrets-smoke && node ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
56
56
  "prepare": "npm run build",
57
57
  "prepack": "npm run verify:self",
58
58
  "prepublishOnly": "npm run verify:self",
@@ -61,6 +61,8 @@
61
61
  "forbidden-names:check": "node ./scripts/check-forbidden-names.mjs",
62
62
  "study-note:check": "node ./dist/bin/yam.js study-note check --json",
63
63
  "package-boundary:check": "node ./scripts/check-package-boundary.mjs",
64
+ "package-secrets:check": "node ./scripts/check-package-secrets.mjs",
65
+ "package-secrets-smoke": "node ./scripts/package-secrets-smoke.mjs",
64
66
  "dist:freshness": "node ./scripts/check-dist-freshness.mjs",
65
67
  "registry:check": "node ./scripts/check-registry-version.mjs",
66
68
  "cli-smoke": "node ./scripts/cli-smoke.mjs",
package/src/bin/yam.ts CHANGED
@@ -45,6 +45,13 @@ import {
45
45
  checkExternalUpdates
46
46
  } from '../lib/external-updates.js';
47
47
  import type { ExternalUpdateComponent } from '../lib/external-updates.js';
48
+ import {
49
+ BoundedInputError,
50
+ GENERAL_STDIN_MAX_BYTES,
51
+ HOOK_STDIN_MAX_BYTES,
52
+ readBoundedStdinJson,
53
+ readBoundedStdinText
54
+ } from '../lib/bounded-input.js';
48
55
 
49
56
  type AnyRecord = Record<string, any>;
50
57
 
@@ -2022,7 +2029,18 @@ async function hookRun(args = []) {
2022
2029
  console.log(JSON.stringify({ continue: true }));
2023
2030
  return;
2024
2031
  }
2025
- const input = await readStdinJson();
2032
+ let input: AnyRecord;
2033
+ try {
2034
+ input = await readStdinJson();
2035
+ } catch (error) {
2036
+ if (!(error instanceof BoundedInputError)) throw error;
2037
+ const code = error.code === 'invalid_json' ? 'invalid_hook_json' : error.code;
2038
+ console.log(JSON.stringify({
2039
+ continue: true,
2040
+ systemMessage: `yam-${profile} hook input rejected: ${code}; continuing without using or echoing the payload.`
2041
+ }));
2042
+ return;
2043
+ }
2026
2044
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
2027
2045
  const cwd = String(input?.cwd || process.cwd());
2028
2046
  if (profile === 'study-note' && event === 'Stop') {
@@ -2044,15 +2062,7 @@ async function hookRun(args = []) {
2044
2062
  }
2045
2063
 
2046
2064
  async function readStdinJson() {
2047
- const chunks = [];
2048
- for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
2049
- const text = Buffer.concat(chunks).toString('utf8').trim();
2050
- if (!text) return {};
2051
- try {
2052
- return JSON.parse(text);
2053
- } catch {
2054
- return {};
2055
- }
2065
+ return readBoundedStdinJson(process.stdin, HOOK_STDIN_MAX_BYTES);
2056
2066
  }
2057
2067
 
2058
2068
  function extractPrompt(input: AnyRecord = {}) {
@@ -2685,9 +2695,7 @@ function redactSensitiveText(text = '') {
2685
2695
 
2686
2696
  async function readStdinTextIfAvailable() {
2687
2697
  if (process.stdin.isTTY) return '';
2688
- const chunks = [];
2689
- for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
2690
- return Buffer.concat(chunks).toString('utf8').trim();
2698
+ return (await readBoundedStdinText(process.stdin, GENERAL_STDIN_MAX_BYTES)).trim();
2691
2699
  }
2692
2700
 
2693
2701
  async function loop(args = []) {
@@ -0,0 +1,59 @@
1
+ export const HOOK_STDIN_MAX_BYTES = 1024 * 1024;
2
+ export const GENERAL_STDIN_MAX_BYTES = 4 * 1024 * 1024;
3
+
4
+ export type BoundedInputErrorCode = 'input_too_large' | 'invalid_json';
5
+
6
+ export class BoundedInputError extends Error {
7
+ readonly code: BoundedInputErrorCode;
8
+ readonly maxBytes?: number;
9
+
10
+ constructor(code: BoundedInputErrorCode, options: { maxBytes?: number } = {}) {
11
+ const detail = code === 'input_too_large' && options.maxBytes
12
+ ? `stdin input exceeds the ${options.maxBytes}-byte limit`
13
+ : 'stdin input is not a valid JSON object';
14
+ super(`[${code}] ${detail}`);
15
+ this.name = 'BoundedInputError';
16
+ this.code = code;
17
+ this.maxBytes = options.maxBytes;
18
+ }
19
+ }
20
+
21
+ export async function readBoundedStdinText(
22
+ input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>,
23
+ maxBytes: number
24
+ ): Promise<string> {
25
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
26
+ throw new Error('stdin byte limit must be a positive safe integer');
27
+ }
28
+
29
+ const chunks: Buffer[] = [];
30
+ let totalBytes = 0;
31
+ for await (const chunk of input) {
32
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
33
+ totalBytes += bytes.byteLength;
34
+ if (totalBytes > maxBytes) {
35
+ throw new BoundedInputError('input_too_large', { maxBytes });
36
+ }
37
+ chunks.push(bytes);
38
+ }
39
+ return Buffer.concat(chunks, totalBytes).toString('utf8');
40
+ }
41
+
42
+ export async function readBoundedStdinJson(
43
+ input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>,
44
+ maxBytes: number
45
+ ): Promise<Record<string, unknown>> {
46
+ const text = (await readBoundedStdinText(input, maxBytes)).trim();
47
+ if (!text) return {};
48
+
49
+ try {
50
+ const value = JSON.parse(text);
51
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
52
+ throw new BoundedInputError('invalid_json');
53
+ }
54
+ return value;
55
+ } catch (error) {
56
+ if (error instanceof BoundedInputError) throw error;
57
+ throw new BoundedInputError('invalid_json');
58
+ }
59
+ }
@@ -479,6 +479,12 @@ async function applyYam(currentYamVersion: string, deps: ResolvedDependencies) {
479
479
  break;
480
480
  }
481
481
  }
482
+ if (!postFailure) {
483
+ const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
484
+ const validation = doctorValidation('yam_finalize_doctor', doctor);
485
+ checks.push(validation.check);
486
+ if (!validation.passed) postFailure = `yam_finalize_doctor failed: ${validation.error}`;
487
+ }
482
488
  if (postFailure) {
483
489
  const rollback = await rollbackYam(check.installed_version, deps, checks);
484
490
  return persistReceipt(baseReceipt(check, 'failed', checks, {
@@ -521,10 +527,19 @@ async function rollbackYam(
521
527
  const install = await deps.run('yam', ['install'], { env: deps.env, timeout: 180000 });
522
528
  const version = await deps.run('yam', ['version'], { env: deps.env, timeout: 30000 });
523
529
  const status = await deps.run('yam', ['status'], { env: deps.env, timeout: 120000 });
530
+ const doctor = await deps.run('yam', ['doctor', '--json'], { env: deps.env, timeout: 180000 });
531
+ const doctorResult = doctorValidation('automatic_yam_rollback_doctor', doctor);
524
532
  checks.push(commandCheck('automatic_yam_rollback_skills', install));
525
533
  checks.push(commandCheck('automatic_yam_rollback_version', version));
526
534
  checks.push(commandCheck('automatic_yam_rollback_status', status));
527
- return install.ok && version.ok && extractVersion(version.stdout) === previousVersion && status.ok;
535
+ checks.push(doctorResult.check);
536
+ return (
537
+ install.ok
538
+ && version.ok
539
+ && extractVersion(version.stdout) === previousVersion
540
+ && status.ok
541
+ && doctorResult.passed
542
+ );
528
543
  }
529
544
 
530
545
  async function applyScrapling(deps: ResolvedDependencies) {
@@ -967,7 +982,7 @@ function yamRollback(previousVersion: string) {
967
982
  automatic: false,
968
983
  previous_target: previousVersion,
969
984
  guidance: previousVersion
970
- ? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, and \`yam status\`.`
985
+ ? `Reinstall the exact prior package with \`npm install -g yam-flow@${previousVersion}\`, then run \`yam install\`, \`yam version\`, \`yam status\`, and \`yam doctor --json\`.`
971
986
  : 'Inspect the global npm installation before attempting a manual rollback.'
972
987
  };
973
988
  }
@@ -1008,6 +1023,61 @@ function commandCheck(id: string, result: ExternalCommandResult): ExternalCompon
1008
1023
  };
1009
1024
  }
1010
1025
 
1026
+ function doctorValidation(id: string, result: ExternalCommandResult) {
1027
+ if (!result.ok) {
1028
+ const note = boundedNote(`command failed: ${commandNote(result)}`);
1029
+ return {
1030
+ passed: false,
1031
+ error: note,
1032
+ check: { id, status: 'failed' as const, note }
1033
+ };
1034
+ }
1035
+
1036
+ let report: JsonRecord;
1037
+ try {
1038
+ const parsed = JSON.parse(String(result.stdout || ''));
1039
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1040
+ throw new Error('not an object');
1041
+ }
1042
+ report = parsed;
1043
+ } catch {
1044
+ const note = 'yam doctor returned invalid JSON';
1045
+ return {
1046
+ passed: false,
1047
+ error: note,
1048
+ check: { id, status: 'failed' as const, note }
1049
+ };
1050
+ }
1051
+
1052
+ if (report.schema !== 'yam.doctor.v1') {
1053
+ const note = 'yam doctor returned an unexpected schema';
1054
+ return {
1055
+ passed: false,
1056
+ error: note,
1057
+ check: { id, status: 'failed' as const, note }
1058
+ };
1059
+ }
1060
+ if (report.ok !== true) {
1061
+ const note = 'yam.doctor.v1 reported ok=false';
1062
+ return {
1063
+ passed: false,
1064
+ error: note,
1065
+ check: { id, status: 'failed' as const, note }
1066
+ };
1067
+ }
1068
+
1069
+ const note = 'yam.doctor.v1 reported ok=true';
1070
+ return {
1071
+ passed: true,
1072
+ error: '',
1073
+ check: { id, status: 'passed' as const, note }
1074
+ };
1075
+ }
1076
+
1077
+ function boundedNote(text: string) {
1078
+ return redactSensitiveText(String(text || '')).slice(0, 800);
1079
+ }
1080
+
1011
1081
  function commandNote(result: ExternalCommandResult) {
1012
1082
  const text = redactSensitiveText([result.stdout, result.stderr].filter(Boolean).join('\n'));
1013
1083
  const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { compareStableText } from './stable-order.js';
4
5
 
5
6
  export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
6
7
  export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
@@ -211,7 +212,7 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
211
212
  const files: InstallFileDigest[] = [];
212
213
  async function walk(current: string, relative: string): Promise<void> {
213
214
  const entries = await fsp.readdir(current, { withFileTypes: true });
214
- entries.sort((left, right) => left.name.localeCompare(right.name));
215
+ entries.sort((left, right) => compareStableText(left.name, right.name));
215
216
  for (const entry of entries) {
216
217
  const absolute = path.join(current, entry.name);
217
218
  const nextRelative = relative ? path.join(relative, entry.name) : entry.name;
@@ -226,7 +227,7 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
226
227
  }
227
228
 
228
229
  await walk(root, '');
229
- return files.sort((left, right) => left.path.localeCompare(right.path));
230
+ return files.sort((left, right) => compareStableText(left.path, right.path));
230
231
  }
231
232
 
232
233
  async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
@@ -253,7 +254,7 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
253
254
  });
254
255
  }
255
256
  }
256
- return files.sort((left, right) => left.path.localeCompare(right.path));
257
+ return files.sort((left, right) => compareStableText(left.path, right.path));
257
258
  }
258
259
 
259
260
  async function copyTree(source: string, target: string): Promise<void> {
@@ -278,8 +279,12 @@ async function copyTree(source: string, target: string): Promise<void> {
278
279
  }
279
280
 
280
281
  function manifestDigest(files: InstallFileDigest[]) {
282
+ return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
283
+ }
284
+
285
+ function digestManifestFiles(files: InstallFileDigest[]) {
281
286
  const digest = createHash('sha256');
282
- for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
287
+ for (const file of files) {
283
288
  digest.update(file.path);
284
289
  digest.update('\0');
285
290
  digest.update(String(file.bytes));
@@ -290,6 +295,20 @@ function manifestDigest(files: InstallFileDigest[]) {
290
295
  return digest.digest('hex');
291
296
  }
292
297
 
298
+ function allowsLegacyManifestOrder(version: unknown) {
299
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version || ''));
300
+ if (!match) return false;
301
+ const major = Number(match[1]);
302
+ const minor = Number(match[2]);
303
+ const patch = Number(match[3]);
304
+ return major < 2 || (major === 2 && (minor < 5 || (minor === 5 && patch === 0)));
305
+ }
306
+
307
+ function receiptManifestDigestMatches(files: InstallFileDigest[], digest: unknown, version: unknown) {
308
+ if (digest === manifestDigest(files)) return true;
309
+ return allowsLegacyManifestOrder(version) && digest === digestManifestFiles(files);
310
+ }
311
+
293
312
  function manifestIssues(expected: InstallFileDigest[], actual: InstallFileDigest[], label: string) {
294
313
  const issues: string[] = [];
295
314
  const expectedByPath = new Map(expected.map((file) => [file.path, file]));
@@ -379,7 +398,7 @@ function ownershipReceiptIssues(
379
398
  if (value.integrity?.file_count !== files.length) {
380
399
  issues.push('install receipt file count does not match its manifest');
381
400
  }
382
- if (value.integrity?.source_digest !== manifestDigest(files)) {
401
+ if (!receiptManifestDigestMatches(files, value.integrity?.source_digest, value.package?.version)) {
383
402
  issues.push('install receipt source digest does not match its manifest');
384
403
  }
385
404
  if (Array.isArray(value.skills)) {
@@ -0,0 +1,4 @@
1
+ export function compareStableText(left: string, right: string): number {
2
+ if (left === right) return 0;
3
+ return left < right ? -1 : 1;
4
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { constants as fsConstants } from 'node:fs';
3
3
  import fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import { compareStableText } from './stable-order.js';
5
6
 
6
7
  export interface InspectedImageFile {
7
8
  path: string;
@@ -102,7 +103,7 @@ export async function upsertUeyeAsset(input: {
102
103
  };
103
104
  if (existingIndex >= 0) manifest.assets[existingIndex] = entry;
104
105
  else manifest.assets.push(entry);
105
- manifest.assets.sort((left, right) => left.id.localeCompare(right.id));
106
+ manifest.assets.sort((left, right) => compareStableText(left.id, right.id));
106
107
  manifest.updated_at = new Date().toISOString();
107
108
  await writeJsonAtomic(manifestPath, manifest);
108
109
  const warnings = assetWarnings(entry);
@@ -205,7 +206,7 @@ export async function archiveUeyeRevision(input: {
205
206
  } else {
206
207
  history.revisions.push(entry);
207
208
  }
208
- history.revisions.sort((left, right) => left.round - right.round || left.artifact_id.localeCompare(right.artifact_id));
209
+ history.revisions.sort((left, right) => left.round - right.round || compareStableText(left.artifact_id, right.artifact_id));
209
210
  history.updated_at = new Date().toISOString();
210
211
  await writeJsonAtomic(manifestPath, history);
211
212
  return {
package/yam.manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yam",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "principles": [
5
5
  "Direction before execution.",
6
6
  "Start fast.",