polygram 0.38.6 → 0.38.9

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.
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ // Stamped at process entry, before stdin is touched. Stamping after the drain
4
+ // measures the writer rather than the hook, which silently inflates every
5
+ // margin derived from the capture.
6
+ const OBSERVED_AT_EPOCH_MS = Date.now();
7
+
8
+ const { buildCaptureRecord, writeCaptureRecord } = require('./observer-capture.js');
9
+
10
+ const MAX_PAYLOAD_BYTES = 256 * 1024;
11
+ const STDIN_TIMEOUT_MS = 5_000;
12
+
13
+ // An oversized payload is rejected whole. Parsing the prefix that arrived
14
+ // before the limit would record a truncated turn as a complete observation.
15
+ function readPayload(stdin, limit, timeoutMs) {
16
+ return new Promise((resolve) => {
17
+ const chunks = [];
18
+ let bytes = 0;
19
+ let settled = false;
20
+ const finish = (status, payload) => {
21
+ if (settled) return;
22
+ settled = true;
23
+ clearTimeout(timer);
24
+ stdin.removeAllListeners();
25
+ stdin.pause();
26
+ resolve({ status, payload, bytes });
27
+ };
28
+ const timer = setTimeout(() => finish('timeout', null), timeoutMs);
29
+ if (typeof timer.unref === 'function') timer.unref();
30
+ stdin.on('data', (chunk) => {
31
+ bytes += chunk.length;
32
+ if (bytes > limit) {
33
+ finish('overflow', null);
34
+ return;
35
+ }
36
+ chunks.push(chunk);
37
+ });
38
+ stdin.once('error', () => finish('unreadable', null));
39
+ stdin.once('end', () => finish('ok', Buffer.concat(chunks).toString('utf8')));
40
+ });
41
+ }
42
+
43
+ async function main(argv, stdin) {
44
+ const [event, captureDir] = argv;
45
+ if (typeof event !== 'string' || event.length === 0
46
+ || typeof captureDir !== 'string' || captureDir.length === 0) {
47
+ process.setExitCode(2);
48
+ return;
49
+ }
50
+ const read = await readPayload(stdin, MAX_PAYLOAD_BYTES, STDIN_TIMEOUT_MS);
51
+ let status = read.status;
52
+ let parsed = null;
53
+ if (status === 'ok') {
54
+ try {
55
+ parsed = JSON.parse(read.payload);
56
+ } catch {
57
+ status = 'unparsable';
58
+ }
59
+ }
60
+ writeCaptureRecord(captureDir, buildCaptureRecord({
61
+ event,
62
+ status,
63
+ observedAtEpochMs: OBSERVED_AT_EPOCH_MS,
64
+ payload: read.payload,
65
+ payloadBytes: read.bytes,
66
+ parsed,
67
+ }));
68
+ }
69
+
70
+ main(process.argv.slice(2), process.stdin).catch(() => {
71
+ // A hook that cannot record its observation must not take the turn down
72
+ // with it; the missing capture is the signal the gate reads.
73
+ process.setExitCode(1);
74
+ });
@@ -376,6 +376,7 @@ function createCodexRuntimeController({
376
376
  codexHome,
377
377
  binaryPath: configured.binary ?? processEnv.POLYGRAM_CODEX_BIN,
378
378
  sessionLauncher,
379
+ hooks: configured.hooks ?? null,
379
380
  daemonSecretRoots: [
380
381
  ...new Set([...defaultDaemonSecretRoots, ...configuredSecretRoots]),
381
382
  ],
@@ -452,14 +453,28 @@ function createCodexRuntimeController({
452
453
  }
453
454
 
454
455
  async function prepareCodexRequest({ sessionKey, request, state }) {
456
+ const configured = deploymentConfig();
457
+ const hookConfigFingerprint = digest(configured.hooks);
455
458
  const existing = receiptsBySession.get(sessionKey);
456
459
  if (existing && sameProfileRequest(
457
460
  existing.receipt.expectedStaticProfile,
458
461
  request,
459
- )) {
462
+ ) && existing.hookConfigFingerprint === hookConfigFingerprint) {
460
463
  const catalogAgeMs = now() - existing.catalogPreparedAt;
461
464
  let currentCredentialState;
462
465
  try {
466
+ if (existing.receipt.expectedStaticProfile.hookManifest != null) {
467
+ if (typeof runtimeProfileBuilder.reattest !== 'function') {
468
+ throw controllerError(
469
+ 'Codex hook profile cannot be re-attested.',
470
+ 'CODEX_OWNED_HOOKS_DRIFT',
471
+ 'Discard the profile and restore the hook-aware runtime builder.',
472
+ );
473
+ }
474
+ runtimeProfileBuilder.reattest(
475
+ existing.receipt.expectedStaticProfile,
476
+ );
477
+ }
463
478
  const homeAttestation = await attestCodexHome(
464
479
  existing.receipt.expectedStaticProfile.codexHome,
465
480
  existing.receipt.expectedStaticProfile.ownedConfigSha256,
@@ -478,14 +493,17 @@ function createCodexRuntimeController({
478
493
  }
479
494
  invalidatePreparedSession(sessionKey, existing);
480
495
  }
496
+ if (existing && receiptsBySession.get(sessionKey) === existing) {
497
+ invalidatePreparedSession(sessionKey, existing);
498
+ }
481
499
 
482
- const configured = deploymentConfig();
483
500
  const key = digest({
484
501
  sessionKey,
485
502
  request,
486
503
  codexHome: configured.codexHome,
487
504
  binaryPath: configured.binaryPath,
488
505
  sessionLauncher: configured.sessionLauncher,
506
+ hooks: configured.hooks,
489
507
  daemonSecretRoots: configured.daemonSecretRoots,
490
508
  });
491
509
  if (preparingByKey.has(key)) return preparingByKey.get(key);
@@ -504,7 +522,18 @@ function createCodexRuntimeController({
504
522
  effort: request.effort,
505
523
  processEnv,
506
524
  sessionLauncher: configured.sessionLauncher,
525
+ ...(configured.hooks == null ? {} : { hooks: configured.hooks }),
507
526
  });
527
+ if (expectedStaticProfile.hookManifest != null) {
528
+ if (typeof runtimeProfileBuilder.reattest !== 'function') {
529
+ throw controllerError(
530
+ 'Codex hook profile cannot be re-attested.',
531
+ 'CODEX_OWNED_HOOKS_DRIFT',
532
+ 'Discard the profile and restore the hook-aware runtime builder.',
533
+ );
534
+ }
535
+ runtimeProfileBuilder.reattest(expectedStaticProfile);
536
+ }
508
537
  const homeAttestation = await attestCodexHome(
509
538
  expectedStaticProfile.codexHome,
510
539
  expectedStaticProfile.ownedConfigSha256,
@@ -533,6 +562,13 @@ function createCodexRuntimeController({
533
562
  'Choose a model and effort returned by the authenticated Codex catalog.',
534
563
  );
535
564
  }
565
+ if (digest(deploymentConfig().hooks) !== hookConfigFingerprint) {
566
+ throw controllerError(
567
+ 'Codex hook configuration changed during preflight.',
568
+ 'CODEX_PREFLIGHT_PROFILE_MISMATCH',
569
+ 'Retry after configuration changes settle.',
570
+ );
571
+ }
536
572
  const receipt = catalog.spawnProfile;
537
573
  const entry = {
538
574
  receipt,
@@ -543,6 +579,7 @@ function createCodexRuntimeController({
543
579
  catalogPreparedAt: now(),
544
580
  credentialStateFingerprint: attestedCredentialState,
545
581
  deploymentIdentity,
582
+ hookConfigFingerprint,
546
583
  };
547
584
  receiptsBySession.set(sessionKey, entry);
548
585
  return entry;
@@ -681,12 +718,25 @@ function createCodexRuntimeController({
681
718
  'Discard the stale spawn and rerun exact preflight.',
682
719
  );
683
720
  }
721
+ if (expectedStaticProfile.hookManifest != null) {
722
+ if (typeof runtimeProfileBuilder.reattest !== 'function') {
723
+ throw controllerError(
724
+ 'Codex hook profile cannot be re-attested before spawn.',
725
+ 'CODEX_OWNED_HOOKS_DRIFT',
726
+ 'Discard the profile and restore the hook-aware runtime builder.',
727
+ );
728
+ }
729
+ runtimeProfileBuilder.reattest(expectedStaticProfile);
730
+ }
684
731
  return new orchestra.CodexAppServerClient({
685
732
  binary: expectedStaticProfile.binary,
686
733
  codexHome: expectedStaticProfile.codexHome,
687
734
  cwd: expectedStaticProfile.cwd,
688
735
  env: expectedStaticProfile.env,
689
736
  expectedConfigSha256: expectedStaticProfile.ownedConfigSha256,
737
+ ...(expectedStaticProfile.hookManifest == null ? {} : {
738
+ hookManifest: expectedStaticProfile.hookManifest,
739
+ }),
690
740
  ...(expectedStaticProfile.sessionLauncher == null ? {} : {
691
741
  sessionLauncher: expectedStaticProfile.sessionLauncher,
692
742
  expectedSessionLauncherSha256:
@@ -21,6 +21,12 @@ const {
21
21
  characterizePinnedSessionLauncher,
22
22
  resolveCodexTargetPin,
23
23
  } = require('@shumkov/orchestra');
24
+ const {
25
+ CAPTURE_DIRECTORY,
26
+ prepareHookRuntime,
27
+ reattestHookRuntime,
28
+ renderTrustTables,
29
+ } = require('./hook-runtime');
24
30
 
25
31
  const CODEX_PERMISSION_PROFILE_ID = 'polygram-session';
26
32
  const CONTROLLED_PATH = '/usr/bin:/bin';
@@ -375,6 +381,8 @@ function renderOwnedConfig(config, {
375
381
  'trust_level = "untrusted"',
376
382
  '',
377
383
  );
384
+ const hookTables = renderTrustTables(config);
385
+ if (hookTables) lines.push(...hookTables.split('\n'));
378
386
  if (
379
387
  filesystem[':minimal'] !== 'read'
380
388
  || filesystem[codexHome] !== 'deny'
@@ -580,33 +588,6 @@ function provisionRuntimeFiles(options, temporaryRoots) {
580
588
  daemonSecretRoots,
581
589
  });
582
590
  const configPath = path.join(codexHome, 'config.toml');
583
- if (existsSync(configPath)) {
584
- assertOwnedMode(configPath, {
585
- code: 'CODEX_OWNED_CONFIG_DRIFT',
586
- kind: 'owned config.toml',
587
- expectedMode: 0o600,
588
- directory: false,
589
- });
590
- if (readFileSync(configPath, 'utf8') !== rawConfig) {
591
- throw fail(
592
- 'Codex owned config.toml differs from the native-beta policy.',
593
- 'CODEX_OWNED_CONFIG_DRIFT',
594
- 'Back up config.toml outside CODEX_HOME, then remove or move the original and rerun startup to reprovision the exact owned config; Polygram never migrates or overwrites it automatically.',
595
- );
596
- }
597
- } else {
598
- try {
599
- writeFileSync(configPath, rawConfig, { flag: 'wx', mode: 0o600 });
600
- chmodSync(configPath, 0o600);
601
- } catch (error) {
602
- throw fail(
603
- 'Codex owned config.toml could not be provisioned.',
604
- 'CODEX_OWNED_CONFIG_DRIFT',
605
- 'Provision the exact owner-only config before enabling Codex.',
606
- error,
607
- );
608
- }
609
- }
610
591
 
611
592
  const authPath = path.join(codexHome, 'auth.json');
612
593
  if (existsSync(authPath)) {
@@ -624,6 +605,7 @@ function provisionRuntimeFiles(options, temporaryRoots) {
624
605
  daemonSecretRoots,
625
606
  commandHome,
626
607
  commandTmp,
608
+ configPath,
627
609
  ownedConfig,
628
610
  rawConfig,
629
611
  ownedConfigSha256: digest(rawConfig),
@@ -635,6 +617,38 @@ function provisionRuntimeFiles(options, temporaryRoots) {
635
617
  };
636
618
  }
637
619
 
620
+ function provisionBaseConfig(materialized) {
621
+ const { configPath, rawConfig } = materialized;
622
+ if (existsSync(configPath)) {
623
+ assertOwnedMode(configPath, {
624
+ code: 'CODEX_OWNED_CONFIG_DRIFT',
625
+ kind: 'owned config.toml',
626
+ expectedMode: 0o600,
627
+ directory: false,
628
+ });
629
+ if (readFileSync(configPath, 'utf8') !== rawConfig) {
630
+ throw fail(
631
+ 'Codex owned config.toml differs from the native-beta policy.',
632
+ 'CODEX_OWNED_CONFIG_DRIFT',
633
+ 'Back up config.toml outside CODEX_HOME, then remove or move the original and rerun startup to reprovision the exact owned config; Polygram never migrates or overwrites it automatically.',
634
+ );
635
+ }
636
+ return materialized;
637
+ }
638
+ try {
639
+ writeFileSync(configPath, rawConfig, { flag: 'wx', mode: 0o600 });
640
+ chmodSync(configPath, 0o600);
641
+ } catch (error) {
642
+ throw fail(
643
+ 'Codex owned config.toml could not be provisioned.',
644
+ 'CODEX_OWNED_CONFIG_DRIFT',
645
+ 'Provision the exact owner-only config before enabling Codex.',
646
+ error,
647
+ );
648
+ }
649
+ return materialized;
650
+ }
651
+
638
652
  function expectedFilesystem(materialized) {
639
653
  return {
640
654
  glob_scan_max_depth: null,
@@ -831,10 +845,100 @@ async function collectPermissionProfiles(client, workspace) {
831
845
  return profiles.sort((left, right) => left.id.localeCompare(right.id));
832
846
  }
833
847
 
848
+ async function verifyHookPhase(materialized, {
849
+ binaryReceipt,
850
+ sessionLauncherReceipt,
851
+ clientFactory,
852
+ expectedConfigSha256,
853
+ hookManifest,
854
+ phase,
855
+ }) {
856
+ let latchedFault = null;
857
+ const client = clientFactory({
858
+ binary: binaryReceipt.path,
859
+ sessionLauncher: sessionLauncherReceipt?.path ?? null,
860
+ expectedSessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
861
+ cwd: materialized.workspace,
862
+ codexHome: materialized.codexHome,
863
+ env: materialized.env,
864
+ expectedConfigSha256,
865
+ hookManifest,
866
+ onNotification: async () => {
867
+ throw fail(
868
+ 'Codex hook verification received an unexpected notification.',
869
+ 'CODEX_RUNTIME_POLICY_DRIFT',
870
+ 'Disable hooks and inspect the pinned app-server protocol drift.',
871
+ );
872
+ },
873
+ onFault: (outcome) => {
874
+ outcome?.assertActive?.();
875
+ latchedFault = outcome;
876
+ },
877
+ });
878
+ if (
879
+ !client
880
+ || typeof client.start !== 'function'
881
+ || typeof client.verifyHooks !== 'function'
882
+ || typeof client.close !== 'function'
883
+ || typeof client.waitForFault !== 'function'
884
+ ) {
885
+ throw new TypeError('Codex hook clientFactory returned an invalid client');
886
+ }
887
+ let result;
888
+ let failure = null;
889
+ try {
890
+ await client.start();
891
+ result = await client.verifyHooks({ phase });
892
+ } catch (error) {
893
+ failure = error;
894
+ }
895
+ try {
896
+ await client.close();
897
+ } catch (error) {
898
+ throw fail(
899
+ 'Codex hook verification cleanup failed.',
900
+ 'CODEX_RUNTIME_CLIENT_CLOSE_FAILED',
901
+ 'Disable hooks and inspect the pinned app-server child cleanup.',
902
+ error,
903
+ );
904
+ }
905
+ let terminalFault;
906
+ try {
907
+ terminalFault = await client.waitForFault();
908
+ } catch (error) {
909
+ throw fail(
910
+ 'Codex hook verification fault handoff failed.',
911
+ 'CODEX_RUNTIME_CLIENT_FAULT',
912
+ 'Disable hooks and inspect the pinned app-server fault handoff.',
913
+ error,
914
+ );
915
+ }
916
+ if (latchedFault || terminalFault) {
917
+ throw fail(
918
+ 'Codex hook verification client faulted.',
919
+ 'CODEX_RUNTIME_CLIENT_FAULT',
920
+ 'Disable hooks and inspect the pinned app-server fault.',
921
+ );
922
+ }
923
+ if (failure) {
924
+ if (failure instanceof CodexRuntimeProfileError) throw failure;
925
+ throw fail(
926
+ 'Codex hook verification failed closed.',
927
+ failure?.code === 'CODEX_HOOK_TRUST_UNVERIFIED'
928
+ ? 'CODEX_HOOK_TRUST_UNVERIFIED'
929
+ : 'CODEX_RUNTIME_POLICY_DRIFT',
930
+ 'Restore the exact owned hook manifest and runtime policy.',
931
+ failure,
932
+ );
933
+ }
934
+ return result;
935
+ }
936
+
834
937
  async function characterizeRuntime(materialized, {
835
938
  binaryReceipt,
836
939
  sessionLauncherReceipt,
837
940
  clientFactory,
941
+ hookManifest = null,
838
942
  }) {
839
943
  let latchedFault = null;
840
944
  const client = clientFactory({
@@ -845,6 +949,7 @@ async function characterizeRuntime(materialized, {
845
949
  codexHome: materialized.codexHome,
846
950
  env: materialized.env,
847
951
  expectedConfigSha256: materialized.ownedConfigSha256,
952
+ ...(hookManifest == null ? {} : { hookManifest }),
848
953
  onNotification: async () => {
849
954
  throw fail(
850
955
  'Codex runtime characterization received a state notification.',
@@ -871,6 +976,12 @@ async function characterizeRuntime(materialized, {
871
976
  let failure = null;
872
977
  try {
873
978
  await client.start();
979
+ if (hookManifest != null) {
980
+ if (typeof client.verifyHooks !== 'function') {
981
+ throw new TypeError('Codex runtime client lacks hook verification');
982
+ }
983
+ await client.verifyHooks({ phase: 'trusted' });
984
+ }
874
985
  const configRead = await client.request('config/read', {
875
986
  cwd: materialized.workspace,
876
987
  includeLayers: true,
@@ -952,7 +1063,8 @@ function createCodexRuntimeProfileBuilder({
952
1063
  throw new TypeError('Codex runtime profile builder options are invalid');
953
1064
  }
954
1065
 
955
- return Object.freeze({
1066
+ const hookProfiles = new WeakMap();
1067
+ const builder = {
956
1068
  async prepare(options = {}) {
957
1069
  const targetPin = resolveTargetPin();
958
1070
  validateBinaryReceipt(options.binaryReceipt, protocolSchema, targetPin);
@@ -984,15 +1096,59 @@ function createCodexRuntimeProfileBuilder({
984
1096
  'Restore the exact root-owned session launcher before enabling Codex.',
985
1097
  );
986
1098
  }
987
- const materialized = provisionRuntimeFiles(options, temporaryRoots);
1099
+ let materialized = provisionRuntimeFiles(options, temporaryRoots);
1100
+ let hookMaterial = null;
1101
+ if (options.hooks == null) {
1102
+ provisionBaseConfig(materialized);
1103
+ } else {
1104
+ if (options.hooks.enabled === true) {
1105
+ ensurePrivateDirectory(
1106
+ path.join(materialized.workspace, CAPTURE_DIRECTORY),
1107
+ 'CODEX_COMMAND_DIR_UNSAFE',
1108
+ 'hook observation directory',
1109
+ );
1110
+ }
1111
+ const hookState = await prepareHookRuntime({
1112
+ hookOptions: options.hooks,
1113
+ codexHome: materialized.codexHome,
1114
+ workspace: materialized.workspace,
1115
+ baseOwnedConfig: materialized.ownedConfig,
1116
+ baseRawConfig: materialized.rawConfig,
1117
+ renderOwnedConfig: (ownedConfig) => renderOwnedConfig(ownedConfig, {
1118
+ codexHome: materialized.codexHome,
1119
+ workspace: materialized.workspace,
1120
+ daemonSecretRoots: materialized.daemonSecretRoots,
1121
+ }),
1122
+ verifyDiscovery: (hookManifest, expectedConfigSha256) => (
1123
+ verifyHookPhase(materialized, {
1124
+ binaryReceipt: options.binaryReceipt,
1125
+ sessionLauncherReceipt,
1126
+ clientFactory,
1127
+ expectedConfigSha256,
1128
+ hookManifest,
1129
+ phase: 'discovery',
1130
+ })
1131
+ ),
1132
+ });
1133
+ hookMaterial = hookState.hookMaterial;
1134
+ materialized = {
1135
+ ...materialized,
1136
+ ownedConfig: hookState.ownedConfig,
1137
+ rawConfig: hookState.rawConfig,
1138
+ ownedConfigSha256: digest(hookState.rawConfig),
1139
+ hookManifest: hookState.hookManifest,
1140
+ hookArtifactsSha256: hookState.hookArtifactsSha256,
1141
+ };
1142
+ }
988
1143
  const characterized = await characterizeRuntime(materialized, {
989
1144
  binaryReceipt: options.binaryReceipt,
990
1145
  sessionLauncherReceipt,
991
1146
  clientFactory,
1147
+ hookManifest: materialized.hookManifest ?? null,
992
1148
  });
993
1149
  const model = requiredString(options.model, 'model');
994
1150
  const effort = requiredString(options.effort, 'reasoning effort');
995
- return deepFreeze({
1151
+ const profile = deepFreeze({
996
1152
  runtime: 'codex',
997
1153
  binary: options.binaryReceipt.path,
998
1154
  target: targetPin.target,
@@ -1016,9 +1172,37 @@ function createCodexRuntimeProfileBuilder({
1016
1172
  effort,
1017
1173
  sessionLauncher: sessionLauncherReceipt?.path ?? null,
1018
1174
  sessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
1175
+ ...(materialized.hookManifest == null ? {} : {
1176
+ hookManifest: materialized.hookManifest,
1177
+ hookArtifactsSha256: materialized.hookArtifactsSha256,
1178
+ }),
1019
1179
  });
1180
+ if (hookMaterial != null) {
1181
+ hookProfiles.set(profile, Object.freeze({
1182
+ hookOptions: Object.freeze({ ...options.hooks }),
1183
+ codexHome: materialized.codexHome,
1184
+ workspace: materialized.workspace,
1185
+ }));
1186
+ }
1187
+ return profile;
1020
1188
  },
1021
- });
1189
+ reattest(profile) {
1190
+ if (profile?.hookManifest == null) return null;
1191
+ const binding = hookProfiles.get(profile);
1192
+ if (!binding) {
1193
+ throw fail(
1194
+ 'Codex hook profile is not owned by this runtime builder.',
1195
+ 'CODEX_OWNED_HOOKS_DRIFT',
1196
+ 'Discard the stale profile and rerun exact preflight.',
1197
+ );
1198
+ }
1199
+ return reattestHookRuntime({
1200
+ ...binding,
1201
+ expected: profile,
1202
+ });
1203
+ },
1204
+ };
1205
+ return Object.freeze(builder);
1022
1206
  }
1023
1207
 
1024
1208
  const defaultBuilder = createCodexRuntimeProfileBuilder();
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.38.6",
3
+ "version": "0.38.9",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
7
7
  "polygram": "polygram.js",
8
8
  "polygram-split-db": "scripts/split-db.js",
9
9
  "polygram-ipc": "scripts/ipc-smoke.js",
10
- "polygram-doctor": "scripts/doctor.js"
10
+ "polygram-doctor": "scripts/doctor.js",
11
+ "polygram-codex-hook-artifacts": "scripts/codex-hook-artifacts.js",
12
+ "polygram-build-codex-hook-bundle": "scripts/build-codex-hook-bundle.js"
11
13
  },
12
14
  "files": [
13
15
  "polygram.js",
@@ -16,11 +18,14 @@
16
18
  "scripts/split-db.js",
17
19
  "scripts/ipc-smoke.js",
18
20
  "scripts/doctor.js",
21
+ "scripts/build-codex-hook-bundle.js",
22
+ "scripts/codex-hook-artifacts.js",
19
23
  "skills/",
20
24
  "commands/",
21
25
  ".claude-plugin/",
22
26
  "ops/polygram.plist.example",
23
27
  "ops/README.md",
28
+ "docs/2026-08-16-003-codex-protected-hook-artifact-runbook.md",
24
29
  "config.example.json"
25
30
  ],
26
31
  "scripts": {
@@ -63,7 +68,8 @@
63
68
  "dependencies": {
64
69
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
70
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.19",
71
+ "@shumkov/orchestra": "0.10.21",
72
+ "acorn": "8.18.0",
67
73
  "better-sqlite3": "^12.9.0",
68
74
  "grammy": "^1.42.0",
69
75
  "markdown-it": "^14.1.1",
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Builds the single self-contained Codex hook bundle from the checked-in
5
+ // sources. The build is a pure function of those sources, so `--check` is the
6
+ // drift gate: a bundle that no longer regenerates is never installed.
7
+
8
+ const { createHash } = require('node:crypto');
9
+ const { readFileSync, writeFileSync } = require('node:fs');
10
+
11
+ const {
12
+ HOOK_BUNDLE_PATH,
13
+ HOOK_BUNDLE_SOURCES,
14
+ buildHookBundle,
15
+ } = require('../lib/codex/hook-bundle');
16
+
17
+ const USAGE = 'usage: build-codex-hook-bundle [--check] [--out <path>]';
18
+
19
+ function parseArgs(argv) {
20
+ const options = { check: false, out: HOOK_BUNDLE_PATH };
21
+ for (let index = 0; index < argv.length; index += 1) {
22
+ const argument = argv[index];
23
+ if (argument === '--check') {
24
+ options.check = true;
25
+ } else if (argument === '--out') {
26
+ index += 1;
27
+ options.out = argv[index];
28
+ if (typeof options.out !== 'string' || options.out.length === 0) {
29
+ throw new Error(USAGE);
30
+ }
31
+ } else {
32
+ throw new Error(`${USAGE}\nunknown argument ${argument}`);
33
+ }
34
+ }
35
+ return options;
36
+ }
37
+
38
+ function main(argv) {
39
+ const options = parseArgs(argv);
40
+ const bundle = buildHookBundle();
41
+ const sha256 = createHash('sha256').update(bundle).digest('hex');
42
+
43
+ if (options.check) {
44
+ let current = null;
45
+ try {
46
+ current = readFileSync(options.out, 'utf8');
47
+ } catch {
48
+ process.stderr.write(`missing bundle at ${options.out}\n`);
49
+ return 1;
50
+ }
51
+ if (current !== bundle) {
52
+ process.stderr.write(
53
+ `bundle at ${options.out} is not what the sources build; `
54
+ + 'rebuild it in the same change as the sources\n',
55
+ );
56
+ return 1;
57
+ }
58
+ process.stdout.write(`ok ${sha256}\n`);
59
+ return 0;
60
+ }
61
+
62
+ writeFileSync(options.out, bundle, { mode: 0o644 });
63
+ process.stdout.write(
64
+ `${options.out}\nsources ${HOOK_BUNDLE_SOURCES.join(' ')}\nsha256 ${sha256}\n`,
65
+ );
66
+ return 0;
67
+ }
68
+
69
+ try {
70
+ process.exitCode = main(process.argv.slice(2));
71
+ } catch (error) {
72
+ process.stderr.write(`${error.message}\n`);
73
+ process.exitCode = 1;
74
+ }