instar 1.3.1612 → 1.3.1613

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.
Files changed (120) hide show
  1. package/dashboard/console/console.js +14 -2
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +31 -7
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/AppConversationStore.d.ts +2 -0
  6. package/dist/core/AppConversationStore.d.ts.map +1 -1
  7. package/dist/core/AppConversationStore.js +8 -1
  8. package/dist/core/AppConversationStore.js.map +1 -1
  9. package/dist/core/AppRecordSignatures.d.ts +23 -2
  10. package/dist/core/AppRecordSignatures.d.ts.map +1 -1
  11. package/dist/core/AppRecordSignatures.js +32 -5
  12. package/dist/core/AppRecordSignatures.js.map +1 -1
  13. package/dist/core/LocalAttentionItems.d.ts +49 -0
  14. package/dist/core/LocalAttentionItems.d.ts.map +1 -0
  15. package/dist/core/LocalAttentionItems.js +107 -0
  16. package/dist/core/LocalAttentionItems.js.map +1 -0
  17. package/dist/core/MacDesktopHost.d.ts +3 -0
  18. package/dist/core/MacDesktopHost.d.ts.map +1 -1
  19. package/dist/core/MacDesktopHost.js +10 -0
  20. package/dist/core/MacDesktopHost.js.map +1 -1
  21. package/dist/core/MachineLockStateReader.d.ts.map +1 -1
  22. package/dist/core/MachineLockStateReader.js +6 -1
  23. package/dist/core/MachineLockStateReader.js.map +1 -1
  24. package/dist/core/PostUpdateMigrator.d.ts +6 -0
  25. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  26. package/dist/core/PostUpdateMigrator.js +33 -0
  27. package/dist/core/PostUpdateMigrator.js.map +1 -1
  28. package/dist/core/types.d.ts +12 -0
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/data/standards-guard-index.json +1 -1
  32. package/dist/data/standards-guard-index.meta.json +2 -2
  33. package/dist/data/standards-registry.meta.json +1 -1
  34. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  35. package/dist/messaging/TelegramAdapter.js +7 -8
  36. package/dist/messaging/TelegramAdapter.js.map +1 -1
  37. package/dist/messaging/shared/MessageLogger.d.ts +2 -0
  38. package/dist/messaging/shared/MessageLogger.d.ts.map +1 -1
  39. package/dist/messaging/shared/MessageLogger.js.map +1 -1
  40. package/dist/messaging/slack/SlackAdapter.d.ts +19 -0
  41. package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
  42. package/dist/messaging/slack/SlackAdapter.js +21 -0
  43. package/dist/messaging/slack/SlackAdapter.js.map +1 -1
  44. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts +13 -0
  45. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts.map +1 -1
  46. package/dist/messaging/telegram-origin/OriginForwardToHolder.js +16 -0
  47. package/dist/messaging/telegram-origin/OriginForwardToHolder.js.map +1 -1
  48. package/dist/messaging/telegram-origin/OriginLegacy.d.ts +8 -0
  49. package/dist/messaging/telegram-origin/OriginLegacy.d.ts.map +1 -1
  50. package/dist/messaging/telegram-origin/OriginLegacy.js +34 -6
  51. package/dist/messaging/telegram-origin/OriginLegacy.js.map +1 -1
  52. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts +3 -0
  53. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts.map +1 -1
  54. package/dist/messaging/telegram-origin/TelegramOriginBoot.js +54 -10
  55. package/dist/messaging/telegram-origin/TelegramOriginBoot.js.map +1 -1
  56. package/dist/messaging/telegram-origin/types.d.ts +11 -0
  57. package/dist/messaging/telegram-origin/types.d.ts.map +1 -1
  58. package/dist/messaging/telegram-origin/types.js +15 -0
  59. package/dist/messaging/telegram-origin/types.js.map +1 -1
  60. package/dist/monitoring/promptRelaySettle.d.ts +35 -0
  61. package/dist/monitoring/promptRelaySettle.d.ts.map +1 -0
  62. package/dist/monitoring/promptRelaySettle.js +58 -0
  63. package/dist/monitoring/promptRelaySettle.js.map +1 -0
  64. package/dist/native/instar-desktop-host +0 -0
  65. package/dist/scaffold/templates.d.ts.map +1 -1
  66. package/dist/scaffold/templates.js +3 -1
  67. package/dist/scaffold/templates.js.map +1 -1
  68. package/dist/server/AgentServer.d.ts +15 -0
  69. package/dist/server/AgentServer.d.ts.map +1 -1
  70. package/dist/server/AgentServer.js +78 -20
  71. package/dist/server/AgentServer.js.map +1 -1
  72. package/dist/server/CapabilityIndex.js +2 -2
  73. package/dist/server/CapabilityIndex.js.map +1 -1
  74. package/dist/server/mobileConversationRoutes.d.ts +16 -3
  75. package/dist/server/mobileConversationRoutes.d.ts.map +1 -1
  76. package/dist/server/mobileConversationRoutes.js +36 -8
  77. package/dist/server/mobileConversationRoutes.js.map +1 -1
  78. package/dist/server/mobilePairingRoutes.d.ts +1 -1
  79. package/dist/server/mobilePairingRoutes.d.ts.map +1 -1
  80. package/dist/server/mobilePairingRoutes.js +5 -2
  81. package/dist/server/mobilePairingRoutes.js.map +1 -1
  82. package/dist/server/mobileSlackRoutes.d.ts +162 -0
  83. package/dist/server/mobileSlackRoutes.d.ts.map +1 -0
  84. package/dist/server/mobileSlackRoutes.js +552 -0
  85. package/dist/server/mobileSlackRoutes.js.map +1 -0
  86. package/dist/server/routes.d.ts +2 -0
  87. package/dist/server/routes.d.ts.map +1 -1
  88. package/dist/server/routes.js +59 -3
  89. package/dist/server/routes.js.map +1 -1
  90. package/package.json +1 -1
  91. package/scripts/build-locked-proof.mjs +125 -0
  92. package/scripts/lib/locked-proof-build.mjs +71 -0
  93. package/scripts/locked-proof/configure-proof-agent.mjs +68 -0
  94. package/scripts/locked-proof/create-proof-user.sh +89 -0
  95. package/scripts/locked-proof/driver.mjs +457 -0
  96. package/scripts/locked-proof/lib.mjs +267 -0
  97. package/scripts/locked-proof/pty-feed.py +59 -0
  98. package/scripts/locked-proof/remove-proof-user.sh +18 -0
  99. package/scripts/locked-proof/reset-attempts.py +52 -0
  100. package/scripts/locked-proof/stage-proof-agents.sh +68 -0
  101. package/src/data/builtin-manifest.json +64 -64
  102. package/src/data/standards-guard-index.json +1 -1
  103. package/src/data/standards-guard-index.meta.json +2 -2
  104. package/src/data/standards-registry.meta.json +1 -1
  105. package/src/scaffold/templates.ts +3 -1
  106. package/upgrades/1.3.1613.md +108 -0
  107. package/upgrades/side-effects/console-partial-survives-reload.eli16.md +5 -0
  108. package/upgrades/side-effects/console-partial-survives-reload.md +34 -0
  109. package/upgrades/side-effects/desktop-tests-stop-touching-tcc.eli16.md +13 -0
  110. package/upgrades/side-effects/desktop-tests-stop-touching-tcc.md +65 -0
  111. package/upgrades/side-effects/held-expiry-one-row-per-topic.eli16.md +5 -0
  112. package/upgrades/side-effects/held-expiry-one-row-per-topic.md +37 -0
  113. package/upgrades/side-effects/locked-proof-driver.md +72 -0
  114. package/upgrades/side-effects/locked-proof-test-build.md +64 -0
  115. package/upgrades/side-effects/locked-security-item-no-telegram.md +78 -0
  116. package/upgrades/side-effects/mobile-native-conversations-inc4.md +115 -0
  117. package/upgrades/side-effects/origin-config-unknown-not-missing.eli16.md +16 -0
  118. package/upgrades/side-effects/origin-config-unknown-not-missing.md +38 -0
  119. package/upgrades/side-effects/prompt-gate-settle-before-relay.eli16.md +11 -0
  120. package/upgrades/side-effects/prompt-gate-settle-before-relay.md +57 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Locked-Mac proof run (docs/specs/desktop-locked-screen.md, proof rows P12 and P15):
3
+ * the case table, the expected-results record, the runner and the verdict.
4
+ *
5
+ * Pure of any Mac: the runner talks to a `target` object. driver.mjs supplies the
6
+ * real one (a throwaway macOS user on the proof Mac) or a fake one (tests).
7
+ *
8
+ * Who types the password: the AGENT, through the real remote-desktop viewer page,
9
+ * using the throwaway test user's password from its own vault. The person only
10
+ * does what software cannot: the two macOS permission grants in the test account,
11
+ * and a few local actions at the Mac (unlock locally, switch user, log in again).
12
+ */
13
+ import crypto from 'node:crypto';
14
+
15
+ export const TERMINAL = ['relocked', 'relock-failed', 'left-unlocked', 'closed-not-applicable'];
16
+ export const BLOCKING = ['owed', 'executing', 'watching'];
17
+ export const PROOF_USER = 'instarproof';
18
+ export const VAULT_KEY = 'locked_proof_user_password';
19
+ export const WATCH_MS = 120_000;
20
+ export const GRANT_CEILING_MS = 600_000;
21
+
22
+ /**
23
+ * `at` names when the injection fires:
24
+ * marker:<state> the first time a marker is in that state
25
+ * after-create a marker exists (any state)
26
+ * before-enter all but the last characters posted (from the calibrated pace)
27
+ * after-enter enterPendingAt written, plus 50 ms
28
+ * typing the typist is running, mid-password
29
+ * sent the viewer reported the password sent
30
+ * now immediately after the grant opened
31
+ * `final` is what the Mac must end as: locked | left-unlocked | unlocked-accepted-risk.
32
+ */
33
+ export const CASES = [
34
+ // ── P12: crash tests ──
35
+ { id: '12.1', gate: 'P12', title: 'Node suspended with descriptors open', password: true, inject: [{ action: 'stop-server', at: 'marker:owed' }], cleanup: ['cont-server'], final: 'locked', markerEnd: ['relocked'] },
36
+ { id: '12.2a', gate: 'P12', title: 'Node killed right after marker-create', password: true, inject: [{ action: 'kill-server', at: 'after-create' }], then: ['start-server'], final: 'locked', noSubmission: true },
37
+ { id: '12.2b', gate: 'P12', title: 'Node killed after the first lease write', password: true, inject: [{ action: 'kill-server', at: 'lease-written' }], then: ['start-server'], final: 'locked', noSubmission: true },
38
+ { id: '12.2c', gate: 'P12', title: 'Node killed after guard spawn, before its heartbeat', password: true, inject: [{ action: 'kill-server', at: 'guard-spawned' }], then: ['start-server'], final: 'locked', noSubmission: true },
39
+ { id: '12.2d', gate: 'P12', title: 'Node killed after the ack, before the first character', password: true, inject: [{ action: 'kill-server', at: 'guard-acked' }], then: ['start-server'], final: 'locked' },
40
+ { id: '12.2e', gate: 'P12', title: 'Node killed mid-typing', password: true, inject: [{ action: 'kill-server', at: 'typing' }], then: ['start-server'], final: 'locked' },
41
+ { id: '12.3', gate: 'P12', title: 'tmux killed', password: true, inject: [{ action: 'kill-tmux', at: 'marker:owed' }], then: ['start-server'], final: 'locked' },
42
+ { id: '12.4', gate: 'P12', title: 'launchd kill of the server', password: true, inject: [{ action: 'launchd-kill', at: 'marker:owed' }], then: ['start-server'], final: 'locked' },
43
+ { id: '12.5a', gate: 'P12', title: 'Guard killed in owed', password: true, inject: [{ action: 'kill-guard', at: 'marker:owed' }], final: 'locked', markerEnd: ['relocked'], guardReplaced: true },
44
+ { id: '12.5b', gate: 'P12', title: 'Guard killed in executing', password: true, inject: [{ action: 'stop-viewer', at: 'sent' }, { action: 'kill-guard', at: 'marker:executing' }], final: 'locked', markerEnd: ['relocked'], guardReplaced: true },
45
+ { id: '12.5c', gate: 'P12', title: 'Guard killed in watching', password: true, inject: [{ action: 'stop-viewer', at: 'sent' }, { action: 'kill-guard', at: 'marker:watching' }], final: 'locked', markerEnd: ['relocked'], guardReplaced: true },
46
+ { id: '12.6', gate: 'P12', title: 'Suspended Node with an unreaped helper and guard', password: true, inject: [{ action: 'stop-server', at: 'marker:owed' }, { action: 'kill-helper', at: 'marker:owed' }], cleanup: ['cont-server'], final: 'locked' },
47
+ { id: '12.7', gate: 'P12', title: 'Guard killed mid-watch after Stop, Node alive', password: true, inject: [{ action: 'stop-viewer', at: 'sent' }, { action: 'kill-guard', at: 'marker:watching' }], final: 'locked', markerEnd: ['relocked'], watchStartUnchanged: true },
48
+ { id: '12.8', gate: 'P12', title: 'relock-failed at watch end raises the security item within 10 s, no boot', password: true, inject: [{ action: 'stop-viewer', at: 'sent' }], person: { text: 'Unlock the test account at the Laptop now, and again each time it locks, four times in two minutes. Leave it unlocked after the fourth.', until: 'marker:relock-failed', timeoutMs: 240_000 }, final: 'unlocked-accepted-risk', markerEnd: ['relock-failed'], securityItemWithinMs: 10_000, cleanup: ['lock-now'] },
49
+ { id: '12.9', gate: 'P12', title: 'Typist and guard suspended through deadline admission recovery, typist then resumed', password: true, inject: [{ action: 'stop-typist', at: 'typing' }, { action: 'stop-guard', at: 'typing' }, { action: 'grant-other-agent', at: 'deadline-passed' }, { action: 'cont-typist', at: 'other-grant-tried' }], final: 'locked', markerEnd: ['relock-failed'], producerGone: true, noSubmissionAfterClose: true },
50
+ { id: '12.10', gate: 'P12', title: 'Guard suspended across a takeover, resumed during the next grant', password: true, inject: [{ action: 'stop-guard', at: 'marker:owed' }, { action: 'open-next-grant', at: 'takeover' }, { action: 'cont-old-guard', at: 'next-grant-open' }], final: 'locked', oldGuardGone: true },
51
+ { id: '12.11', gate: 'P12', title: 'Relock failing with Node down', password: true, inject: [{ action: 'kill-server', at: 'sent' }], person: { text: 'Unlock the test account at the Laptop now, and again each time it locks, four times in two minutes. Leave it unlocked after the fourth.', until: 'marker:relock-failed', timeoutMs: 300_000 }, then: ['start-server'], final: 'locked', markerEnd: ['relock-failed'], securityItemAfterBoot: true },
52
+ { id: '12.12', gate: 'P12', title: 'User switch with the guard alive', password: true, inject: [{ action: 'stop-viewer', at: 'sent' }], person: { text: 'At the Laptop, switch to your own account (your own password), wait 30 seconds, then switch back to the test account.', until: 'marker:closed-not-applicable', timeoutMs: 300_000 }, final: 'not-judged', markerEnd: ['closed-not-applicable'], noLockCall: true },
53
+ { id: '12.13', gate: 'P12', title: 'Logout with the guard alive', password: true, inject: [{ action: 'logout-proof-user', at: 'marker:watching' }], person: { text: 'At the Laptop, log in to the test account again.', until: 'proof-user-console', timeoutMs: 300_000 }, final: 'not-judged', markerEnd: ['closed-not-applicable'], noLockCall: true },
54
+ { id: '12.14a', gate: 'P12', title: 'Leave unlocked racing the guard: Leave first', password: true, inject: [{ action: 'leave-unlocked', at: 'unlocked' }, { action: 'revoke', at: 'unlocked' }], final: 'either', markerEnd: ['left-unlocked', 'relocked'] },
55
+ { id: '12.14b', gate: 'P12', title: 'Leave unlocked racing the guard: Stop first', password: true, inject: [{ action: 'revoke', at: 'unlocked' }, { action: 'leave-unlocked', at: 'unlocked', delayMs: 150 }], final: 'either', markerEnd: ['left-unlocked', 'relocked'] },
56
+ { id: '12.15', gate: 'P12', title: 'Leave unlocked followed by a crash', password: true, inject: [{ action: 'leave-unlocked', at: 'unlocked' }, { action: 'kill-server', at: 'marker:left-unlocked' }], then: ['start-server'], final: 'left-unlocked', markerEnd: ['left-unlocked'], typistGone: true, cleanup: ['lock-now'] },
57
+ { id: '12.16', gate: 'P12', title: 'Next grant refused until the duty ends, across agents', password: true, inject: [{ action: 'stop-viewer', at: 'sent' }, { action: 'grant-other-agent', at: 'marker:watching', expectRefusal: 'relock-pending' }], final: 'locked', markerEnd: ['relocked'] },
58
+ { id: '12.17', gate: 'P12', title: 'A stray letter at the shield leaves the grant alive', password: false, inject: [{ action: 'type-letter', at: 'now' }], final: 'locked', grantAlive: true },
59
+ { id: '12.18', gate: 'P12', title: 'Typing refuses without the guard acknowledgement', password: true, inject: [{ action: 'stop-new-guards', at: 'now' }], cleanup: ['cont-guards'], final: 'locked', secretRefused: 'guard-not-alive', noSubmission: true },
60
+ // ── P15: Stop tests ──
61
+ { id: '15.1', gate: 'P15', title: 'Stop immediately before Enter', password: true, inject: [{ action: 'revoke', at: 'before-enter' }], final: 'locked', noSubmissionAfterClose: true },
62
+ { id: '15.2', gate: 'P15', title: 'Stop immediately after Enter', password: true, inject: [{ action: 'revoke', at: 'after-enter' }], final: 'locked', noSubmissionAfterClose: true },
63
+ { id: '15.3', gate: 'P15', title: 'Deadline immediately before Enter', password: true, timing: 'deadline', inject: [{ action: 'wait-deadline', at: 'before-enter' }], final: 'locked', noSubmissionAfterClose: true, timeoutMs: 900_000 },
64
+ { id: '15.4', gate: 'P15', title: 'Deadline immediately after Enter', password: true, timing: 'deadline', inject: [{ action: 'wait-deadline', at: 'after-enter' }], final: 'locked', noSubmissionAfterClose: true, timeoutMs: 900_000 },
65
+ { id: '15.5', gate: 'P15', title: 'Transport expiry immediately before Enter', password: true, inject: [{ action: 'drop-transport', at: 'before-enter' }], final: 'locked', relockAfterResumeWindow: true, noSubmissionAfterClose: true },
66
+ { id: '15.6', gate: 'P15', title: 'Transport expiry immediately after Enter', password: true, inject: [{ action: 'drop-transport', at: 'after-enter' }], final: 'locked', relockAfterResumeWindow: true, noSubmissionAfterClose: true },
67
+ { id: '15.7', gate: 'P15', title: 'Node kill immediately before Enter', password: true, inject: [{ action: 'kill-server', at: 'before-enter' }], then: ['start-server'], final: 'locked', noSubmissionAfterClose: true },
68
+ { id: '15.8', gate: 'P15', title: 'Node kill immediately after Enter', password: true, inject: [{ action: 'kill-server', at: 'after-enter' }], then: ['start-server'], final: 'locked', noSubmissionAfterClose: true },
69
+ { id: '15.9', gate: 'P15', title: 'input-stream suspended mid-password', password: true, inject: [{ action: 'stop-typist', at: 'typing' }, { action: 'revoke', at: 'typing', delayMs: 2_000 }], final: 'locked', typistGone: true, fieldClearedBeforeNextEnter: true },
70
+ { id: '15.10', gate: 'P15', title: 'Delayed completion inside the watch', password: false, inject: [{ action: 'revoke', at: 'now' }], person: { text: 'In about 30 seconds, when I say so, unlock the test account at the Laptop once and leave it.', cueAfterMs: 30_000, until: 'marker:relocked', timeoutMs: 180_000 }, final: 'locked', markerEnd: ['relocked'] },
71
+ { id: '15.11', gate: 'P15', title: 'Delayed completion past the watch', password: false, inject: [{ action: 'revoke', at: 'now' }], person: { text: 'In about 2 minutes 10 seconds, when I say so, unlock the test account at the Laptop once and leave it.', cueAfterMs: 130_000, until: 'unlocked', timeoutMs: 300_000 }, final: 'unlocked-accepted-risk', markerEnd: ['relocked'], cleanup: ['lock-now'] },
72
+ ];
73
+
74
+ export function selectCases(ids) {
75
+ if (!ids || ids.length === 0) return CASES;
76
+ const wanted = new Set(ids);
77
+ const picked = CASES.filter(c => wanted.has(c.id) || wanted.has(c.gate));
78
+ const unknown = [...wanted].filter(id => !CASES.some(c => c.id === id || c.gate === id));
79
+ if (unknown.length) throw new Error(`unknown case(s): ${unknown.join(', ')}`);
80
+ return picked;
81
+ }
82
+
83
+ /** The record written and committed BEFORE any run (plan step S5). */
84
+ export function renderExpected(cases = CASES) {
85
+ const lines = ['| Case | Gate | What happens | Person | Mac must end | Marker must end |', '|---|---|---|---|---|---|'];
86
+ for (const c of cases) {
87
+ const injections = c.inject.map(i => `${i.action} at ${i.at}`).join('; ');
88
+ lines.push(`| ${c.id} | ${c.gate} | ${c.title}: ${injections} | ${c.person ? c.person.text : 'none'} | ${c.final} | ${(c.markerEnd ?? ['any terminal']).join(' or ')} |`);
89
+ }
90
+ return `${lines.join('\n')}\n`;
91
+ }
92
+
93
+ /**
94
+ * A throwaway password: typeable on any layout (lowercase letters and digits only,
95
+ * no characters that move between keyboard layouts), 20 characters, never logged.
96
+ */
97
+ export function generateProofPassword(random = crypto.randomBytes) {
98
+ const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
99
+ const bytes = random(40);
100
+ let out = '';
101
+ for (let i = 0; out.length < 20 && i < bytes.length; i++) {
102
+ if (bytes[i] < 248) out += alphabet[bytes[i] % alphabet.length];
103
+ }
104
+ if (out.length < 20) throw new Error('not enough random bytes');
105
+ return out;
106
+ }
107
+
108
+ /** The attempt counter, reset between batches (plan 4.4); refused while a duty is open. */
109
+ export function resetAttempts(state, today) {
110
+ const markers = state?.markers ?? {};
111
+ const open = Object.entries(markers).filter(([, m]) => BLOCKING.includes(m?.state));
112
+ if (open.length) throw new Error(`refusing to reset attempts while duty ${open[0][0]} is ${open[0][1].state}`);
113
+ return { ...state, attempts: { day: today, count: 0, perRequest: {} } };
114
+ }
115
+
116
+ /** Refuse to act anywhere but a proof install. */
117
+ export function assertProofTarget({ user, version }) {
118
+ if (user !== PROOF_USER) throw new Error(`target user must be "${PROOF_USER}", got "${user}"`);
119
+ if (typeof version !== 'string' || !/-lockedproof\.\d+$/.test(version)) {
120
+ throw new Error(`target agent must run a -lockedproof.N test build, got "${version}"`);
121
+ }
122
+ }
123
+
124
+ function matchAt(at, snap, ctx) {
125
+ if (at === 'now') return true;
126
+ const markers = Object.values(snap.markers ?? {});
127
+ if (at.startsWith('marker:')) return markers.some(m => m.state === at.slice(7));
128
+ if (at === 'after-create') return markers.length > 0;
129
+ if (at === 'unlocked') return snap.lockState === 'unlocked';
130
+ if (at === 'sent') return ctx.sent === true;
131
+ if (at === 'typing') return snap.typistRunning === true;
132
+ if (at === 'after-enter') return markers.some(m => typeof m.enterPendingAt === 'number' && snap.now - m.enterPendingAt >= 50);
133
+ if (at === 'before-enter') return ctx.beforeEnterDue !== undefined && snap.now >= ctx.beforeEnterDue;
134
+ return snap.events?.includes(at) === true;
135
+ }
136
+
137
+ /**
138
+ * Run one case against a target. Every observation is kept as evidence; the
139
+ * verdict compares the evidence with the case's expectation and nothing else.
140
+ */
141
+ export async function runCase(c, target, { pollMs = 250, log = () => {} } = {}) {
142
+ const evidence = { id: c.id, gate: c.gate, startedAt: target.now(), samples: [], fired: [], person: null, notes: [] };
143
+ const sample = async () => {
144
+ const snap = await target.snapshot();
145
+ evidence.samples.push({ t: snap.now, lockState: snap.lockState, markers: snap.markers, dirty: snap.dirty, guard: snap.guardPid ?? null, typist: snap.typistRunning === true });
146
+ return snap;
147
+ };
148
+ const pending = [...c.inject];
149
+ try {
150
+ await target.prepare(c);
151
+ const ctx = { sent: false, beforeEnterDue: undefined };
152
+ const grant = await target.openGrant(c);
153
+ evidence.grantOpenedAt = target.now();
154
+ if (c.password) {
155
+ const pace = target.calibration();
156
+ if (pace) ctx.beforeEnterDue = target.now() + pace.reserveMs + pace.perCharMs * Math.max(0, pace.length - 2);
157
+ void target.typePassword(c).then(
158
+ r => { ctx.sent = r?.sent === true; evidence.secretResult = r ?? null; },
159
+ err => { evidence.secretResult = { error: err instanceof Error ? err.message : String(err) }; });
160
+ }
161
+ const deadline = target.now() + (c.timeoutMs ?? WATCH_MS + GRANT_CEILING_MS / 4);
162
+ let personCued = false;
163
+ while (target.now() < deadline) {
164
+ const snap = await sample();
165
+ for (const step of [...pending]) {
166
+ if (!matchAt(step.at, snap, ctx)) continue;
167
+ if (step.delayMs) await target.sleep(step.delayMs);
168
+ pending.splice(pending.indexOf(step), 1);
169
+ const result = await target.act(step.action, { case: c, step, grant, snap });
170
+ evidence.fired.push({ action: step.action, at: step.at, t: target.now(), result: result ?? null });
171
+ log(`[${c.id}] ${step.action} (${step.at})`);
172
+ }
173
+ if (c.person && !personCued && pending.length === 0) {
174
+ personCued = true;
175
+ if (c.person.cueAfterMs) await target.sleep(c.person.cueAfterMs);
176
+ evidence.person = { cuedAt: target.now(), text: c.person.text };
177
+ await target.askPerson(c.person.text);
178
+ }
179
+ const settled = pending.length === 0 && (!c.person || personCued) && target.settled(snap, c);
180
+ if (settled) break;
181
+ await target.sleep(pollMs);
182
+ }
183
+ for (const step of c.then ?? []) await target.act(step, { case: c });
184
+ const final = await sample();
185
+ evidence.final = { lockState: final.lockState, markers: final.markers, processesGone: final.processesGone !== false };
186
+ evidence.extra = { ...measured(evidence), ...(await target.extraEvidence(c, evidence)) };
187
+ for (const step of c.cleanup ?? []) await target.act(step, { case: c });
188
+ } catch (err) {
189
+ evidence.error = err instanceof Error ? err.message : String(err);
190
+ } finally {
191
+ // Whatever happened: resume every process this case stopped, then lock the proof session.
192
+ const problems = (await target.recover?.(c).catch(e => [String(e)])) ?? ['target has no recover()'];
193
+ if (problems.length) evidence.recoveryProblems = problems;
194
+ }
195
+ evidence.endedAt = target.now();
196
+ evidence.unfired = pending.map(s => `${s.action} at ${s.at}`);
197
+ evidence.verdict = judge(c, evidence);
198
+ return evidence;
199
+ }
200
+
201
+ export const RESUME_WINDOW_MS = 30_000;
202
+
203
+ /** Measurements every target gets from its own samples (the target may add more). */
204
+ export function measured(ev) {
205
+ const out = {};
206
+ const drop = ev.fired.find(f => f.action === 'drop-transport');
207
+ if (drop) {
208
+ const closed = ev.samples.find(s => s.t >= drop.t && Object.values(s.markers ?? {}).length > 0
209
+ && Object.values(s.markers).every(m => TERMINAL.includes(m.state)));
210
+ if (closed) out.relockAfterDropMs = closed.t - drop.t;
211
+ }
212
+ const last = ev.samples[ev.samples.length - 1];
213
+ if (last && typeof last.dirty === 'boolean') out.fieldDirtyAtEnd = last.dirty;
214
+ return out;
215
+ }
216
+
217
+ /** Expected versus actual. Returns { pass, reasons[] }; a reason is a failed expectation. */
218
+ export function judge(c, ev) {
219
+ const reasons = [];
220
+ if (ev.error) reasons.push(`case stopped with an error: ${ev.error}`);
221
+ if (ev.recoveryProblems?.length) reasons.push(`recovery problems: ${ev.recoveryProblems.join('; ')}`);
222
+ if (ev.unfired?.length) reasons.push(`injection never fired: ${ev.unfired.join(', ')}`);
223
+ const markers = Object.values(ev.final?.markers ?? {});
224
+ const terminal = markers.filter(m => TERMINAL.includes(m.state));
225
+ if (markers.some(m => BLOCKING.includes(m.state))) reasons.push('a duty is still open at the end');
226
+ if (c.markerEnd && !terminal.some(m => c.markerEnd.includes(m.state))) {
227
+ reasons.push(`marker ended ${markers.map(m => m.state).join(',') || 'absent'}, expected ${c.markerEnd.join(' or ')}`);
228
+ }
229
+ const lock = ev.final?.lockState;
230
+ if (c.final === 'locked' && lock !== 'locked-in-session' && lock !== 'locked') reasons.push(`Mac ended ${lock}, expected locked`);
231
+ if (c.final === 'left-unlocked' && !terminal.some(m => m.state === 'left-unlocked')) reasons.push('Leave unlocked was not the recorded outcome');
232
+ if (c.final === 'either') {
233
+ const ok = terminal.some(m => m.state === 'left-unlocked') || lock === 'locked-in-session' || lock === 'locked';
234
+ if (!ok) reasons.push(`neither left-unlocked nor locked (ended ${lock})`);
235
+ }
236
+ if (ev.final?.processesGone === false) reasons.push('guard or typist still running after the duty closed');
237
+ const x = ev.extra ?? {};
238
+ if (c.noSubmission && x.submissions > 0) reasons.push(`${x.submissions} password submission(s) where none was allowed`);
239
+ if ((c.noSubmissionAfterClose || c.noSubmission) && x.submissionsAfterClose > 0) reasons.push(`${x.submissionsAfterClose} submission(s) after the duty closed`);
240
+ if (c.guardReplaced && !x.guardReplaced) reasons.push('the killed guard was not replaced');
241
+ if (c.watchStartUnchanged && x.watchStartChanged) reasons.push('the replacement guard restarted the watch');
242
+ if (c.securityItemWithinMs !== undefined && !(x.securityItemMs <= c.securityItemWithinMs)) reasons.push(`security item ${x.securityItemMs === undefined ? 'never raised' : `raised after ${x.securityItemMs} ms`}`);
243
+ if (c.securityItemAfterBoot && !x.securityItemRaised) reasons.push('security item not raised after the server came back');
244
+ if (c.producerGone && !x.producerGone) reasons.push('marker not recorded with producerGone');
245
+ if (c.oldGuardGone && !x.oldGuardGone) reasons.push('the suspended guard was not killed before takeover');
246
+ if (c.typistGone && !x.typistGone) reasons.push('typist not confirmed gone');
247
+ if (c.grantAlive && !x.grantAlive) reasons.push('the grant ended after a stray letter');
248
+ if (c.secretRefused && ev.secretResult?.refused !== c.secretRefused) reasons.push(`expected refusal ${c.secretRefused}, got ${JSON.stringify(ev.secretResult ?? null)}`);
249
+ // An expectation with no measurement fails loudly: an unmeasured check is never a pass.
250
+ if (c.noLockCall) {
251
+ if (typeof x.lockCalls !== 'number') reasons.push('not measured: lock calls against another session (no observable source yet)');
252
+ else if (x.lockCalls > 0) reasons.push(`${x.lockCalls} lock call(s) against another session`);
253
+ }
254
+ if (c.relockAfterResumeWindow) {
255
+ if (typeof x.relockAfterDropMs !== 'number') reasons.push('not measured: relock time after the transport drop');
256
+ else if (x.relockAfterDropMs < RESUME_WINDOW_MS - 1_000) reasons.push(`duty closed ${x.relockAfterDropMs} ms after the drop, inside the ${RESUME_WINDOW_MS} ms resume window`);
257
+ }
258
+ if (c.fieldClearedBeforeNextEnter) {
259
+ if (typeof x.fieldDirtyAtEnd !== 'boolean') reasons.push('not measured: password field state at the end');
260
+ else if (x.fieldDirtyAtEnd) reasons.push('password field left dirty at the end');
261
+ }
262
+ for (const f of ev.fired ?? []) {
263
+ const want = c.inject.find(s => s.action === f.action && s.expectRefusal)?.expectRefusal;
264
+ if (want && f.result?.refused !== want) reasons.push(`${f.action}: expected refusal ${want}, got ${f.result?.refused ?? 'none'}`);
265
+ }
266
+ return { pass: reasons.length === 0, reasons: [...new Set(reasons)] };
267
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Answer a command's password prompt(s) from stdin, through a pseudo-terminal.
3
+
4
+ Used so a secret never appears on a command line (visible in `ps`) or in a log:
5
+ `sysadminctl -addUser ... -password -` prompts on its terminal, and this feeds it.
6
+
7
+ Usage: <secret on stdin> | pty-feed.py [--prompts N] [--timeout S] -- command args...
8
+ Answers up to N prompts (default 1) that contain "assword"; prints nothing the child
9
+ writes (a prompt could echo); exits with the child's status. Fails if no prompt came.
10
+ """
11
+ import os, pty, select, sys, time
12
+
13
+ def main():
14
+ argv = sys.argv[1:]
15
+ prompts, timeout = 1, 60.0
16
+ while argv and argv[0] != '--':
17
+ if argv[0] == '--prompts': prompts = int(argv[1]); argv = argv[2:]
18
+ elif argv[0] == '--timeout': timeout = float(argv[1]); argv = argv[2:]
19
+ else: sys.exit('pty-feed: unknown option %s' % argv[0])
20
+ if not argv or argv[0] != '--' or len(argv) < 2:
21
+ sys.exit('pty-feed: usage: pty-feed.py [--prompts N] [--timeout S] -- command args...')
22
+ cmd = argv[1:]
23
+ secret = sys.stdin.buffer.read().rstrip(b'\n')
24
+ if not secret:
25
+ sys.exit('pty-feed: empty secret on stdin')
26
+ pid, fd = pty.fork()
27
+ if pid == 0:
28
+ os.execvp(cmd[0], cmd)
29
+ answered, buf, deadline = 0, b'', time.time() + timeout
30
+ while True:
31
+ if time.time() > deadline:
32
+ os.kill(pid, 9)
33
+ sys.exit('pty-feed: timed out after %d prompt(s)' % answered)
34
+ r, _, _ = select.select([fd], [], [], 0.2)
35
+ if fd in r:
36
+ try:
37
+ chunk = os.read(fd, 1024)
38
+ except OSError:
39
+ break
40
+ if not chunk:
41
+ break
42
+ buf = (buf + chunk)[-256:]
43
+ if answered < prompts and b'assword' in buf:
44
+ os.write(fd, secret + b'\n')
45
+ answered += 1
46
+ buf = b''
47
+ else:
48
+ done, status = os.waitpid(pid, os.WNOHANG)
49
+ if done:
50
+ code = os.waitstatus_to_exitcode(status)
51
+ if answered == 0: sys.exit('pty-feed: the command never asked for a password')
52
+ sys.exit(code)
53
+ _, status = os.waitpid(pid, 0)
54
+ if answered == 0:
55
+ sys.exit('pty-feed: the command never asked for a password')
56
+ sys.exit(os.waitstatus_to_exitcode(status))
57
+
58
+ if __name__ == '__main__':
59
+ main()
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # Tear down the locked-Mac proof user: stop its agents, log it out, delete it.
3
+ # remove-proof-user.sh [--execute]
4
+ # Its macOS permission rows stay (they name a path that no longer exists). The vault
5
+ # key locked_proof_user_password is left for the operator-side agent to delete.
6
+ set -euo pipefail
7
+ PROOF_USER="instarproof"
8
+ EXECUTE=0
9
+ [ "${1:-}" = "--execute" ] && EXECUTE=1
10
+ echo "Plan: stop $PROOF_USER's processes, log it out, delete the user and its home (sysadminctl -deleteUser)."
11
+ [ "$EXECUTE" -eq 1 ] || { echo "Dry run: nothing done."; exit 0; }
12
+ dscl . -read "/Users/$PROOF_USER" UniqueID >/dev/null 2>&1 || { echo "no user $PROOF_USER"; exit 0; }
13
+ [ "$(stat -f %Su /dev/console)" = "$PROOF_USER" ] && { echo "refusing: $PROOF_USER is on the console; switch to another user first" >&2; exit 1; }
14
+ UID_N="$(id -u "$PROOF_USER")"
15
+ sudo -n launchctl bootout "gui/$UID_N" 2>/dev/null || true
16
+ sudo -n pkill -KILL -U "$PROOF_USER" || true
17
+ sudo -n /usr/sbin/sysadminctl -deleteUser "$PROOF_USER" -secure
18
+ echo "removed $PROOF_USER"
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Reset the locked-screen attempt counter for the proof run (plan section 4.4).
3
+
4
+ Runs AS the throwaway proof user, under the helper's own kernel lock (state.lock),
5
+ with the helper's own atomic write. Refuses while any relock duty is open.
6
+ Usage: reset-attempts.py --execute <locked-store-root>
7
+ Refuses without --execute, and refuses any store other than the proof user's
8
+ (/Users/instarproof/.instar/desktop-helper/locked). Tests may name one extra root
9
+ inside the system temporary directory through LOCKED_PROOF_TEST_ROOT.
10
+ """
11
+ import datetime, fcntl, json, os, sys, tempfile
12
+
13
+ PROOF_STORE = '/Users/instarproof/.instar/desktop-helper/locked'
14
+
15
+ def allowed(root):
16
+ real = os.path.realpath(root)
17
+ if real == PROOF_STORE:
18
+ return True
19
+ test = os.environ.get('LOCKED_PROOF_TEST_ROOT')
20
+ tmp = os.path.realpath(tempfile.gettempdir())
21
+ return bool(test) and real == os.path.realpath(test) and real.startswith(tmp + os.sep) \
22
+ and real.endswith(os.sep + os.path.join('.instar', 'desktop-helper', 'locked'))
23
+
24
+ def main():
25
+ args = sys.argv[1:]
26
+ if '--execute' not in args:
27
+ sys.exit('refusing: dry run (no --execute); nothing reset')
28
+ args = [a for a in args if a != '--execute']
29
+ if len(args) != 1:
30
+ sys.exit('usage: reset-attempts.py --execute <locked-store-root>')
31
+ root = args[0]
32
+ if not allowed(root):
33
+ sys.exit('refusing: %s is not the proof user\'s locked store' % root)
34
+ fd = os.open(os.path.join(root, 'state.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
35
+ fcntl.flock(fd, fcntl.LOCK_EX)
36
+ path = os.path.join(root, 'locked-state.json')
37
+ state = {}
38
+ if os.path.exists(path):
39
+ with open(path) as f:
40
+ state = json.load(f)
41
+ for key, marker in (state.get('markers') or {}).items():
42
+ if (marker or {}).get('state') in ('owed', 'executing', 'watching'):
43
+ sys.exit('refusing: duty %s is %s' % (key, marker['state']))
44
+ state['attempts'] = {'day': datetime.date.today().isoformat(), 'count': 0, 'perRequest': {}}
45
+ tmp = '%s.%d.tmp' % (path, os.getpid())
46
+ with open(tmp, 'w') as f:
47
+ json.dump(state, f)
48
+ os.chmod(tmp, 0o600)
49
+ os.rename(tmp, path)
50
+
51
+ if __name__ == '__main__':
52
+ main()
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # Stage the locked-Mac proof install and its two agents inside the throwaway user.
3
+ #
4
+ # stage-proof-agents.sh --tgz PATH [--execute] install + init + configure (user need not be logged in)
5
+ # stage-proof-agents.sh --start [--execute] start both servers in the user's login session (after first login)
6
+ # stage-proof-agents.sh --finalize [--execute] record each agent as its own desktop front, restart
7
+ #
8
+ # Agent A: locked-proof on port 4098 with a Cloudflare quick tunnel. Agent B:
9
+ # locked-proof-b on port 4097 (for the "across agents" cases). Both run the
10
+ # -lockedproof test build from the user's own prefix, with auto-update off.
11
+ # Without --execute it prints the plan only.
12
+ set -euo pipefail
13
+
14
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ PROOF_USER="instarproof"
16
+ HOME_DIR="/Users/$PROOF_USER"
17
+ PREFIX="$HOME_DIR/.instar/proof-install"
18
+ INSTAR="$PREFIX/node_modules/.bin/instar"
19
+ A="$HOME_DIR/.instar/agents/locked-proof"
20
+ B="$HOME_DIR/.instar/agents/locked-proof-b"
21
+ MODE="stage"; TGZ=""; EXECUTE=0
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ --tgz) TGZ="$2"; shift 2 ;;
25
+ --start) MODE="start"; shift ;;
26
+ --finalize) MODE="finalize"; shift ;;
27
+ --execute) EXECUTE=1; shift ;;
28
+ *) echo "unknown option: $1" >&2; exit 2 ;;
29
+ esac
30
+ done
31
+
32
+ as_user() { sudo -n -u "$PROOF_USER" -H "$@"; }
33
+ in_session() { sudo -n launchctl asuser "$(id -u "$PROOF_USER")" sudo -n -u "$PROOF_USER" -H "$@"; }
34
+
35
+ case "$MODE" in
36
+ stage)
37
+ [ -n "$TGZ" ] || { echo "--tgz is required" >&2; exit 2; }
38
+ case "$(basename "$TGZ")" in *-lockedproof.*.tgz) ;; *) echo "refusing: $TGZ is not a -lockedproof test build" >&2; exit 1 ;; esac
39
+ echo "Plan: install $TGZ into $PREFIX as $PROOF_USER; init $A (:4098, quick tunnel) and $B (:4097); auto-update off; no start-on-login job."
40
+ [ "$EXECUTE" -eq 1 ] || { echo "Dry run: nothing done."; exit 0; }
41
+ dscl . -read "/Users/$PROOF_USER" UniqueID >/dev/null || { echo "user $PROOF_USER does not exist" >&2; exit 1; }
42
+ STAGED="/Users/Shared/instar-locked-proof-$(basename "$TGZ")"
43
+ cp "$TGZ" "$STAGED"; chmod 0644 "$STAGED"
44
+ as_user mkdir -p "$PREFIX"
45
+ as_user npm install --prefix "$PREFIX" --no-audit --no-fund "$STAGED"
46
+ as_user "$INSTAR" init --standalone locked-proof --port 4098 --keep
47
+ as_user "$INSTAR" init --standalone locked-proof-b --port 4097 --keep
48
+ as_user node "$HERE/configure-proof-agent.mjs" "$A" --port 4098 --tunnel --execute
49
+ as_user node "$HERE/configure-proof-agent.mjs" "$B" --port 4097 --execute
50
+ as_user cp "$HERE/reset-attempts.py" "$HOME_DIR/.instar/reset-attempts.py"
51
+ echo "staged; next: the person logs in to $PROOF_USER once, then --start"
52
+ ;;
53
+ start)
54
+ echo "Plan: start both proof servers inside $PROOF_USER's login session."
55
+ [ "$EXECUTE" -eq 1 ] || { echo "Dry run: nothing done."; exit 0; }
56
+ [ "$(stat -f %Su /dev/console)" = "$PROOF_USER" ] || echo "note: $PROOF_USER is not on the console; its session must exist (log in once)"
57
+ in_session "$INSTAR" server start --dir "$A"
58
+ in_session "$INSTAR" server start --dir "$B"
59
+ ;;
60
+ finalize)
61
+ echo "Plan: make each proof agent its own desktop front, then restart both."
62
+ [ "$EXECUTE" -eq 1 ] || { echo "Dry run: nothing done."; exit 0; }
63
+ as_user node "$HERE/configure-proof-agent.mjs" "$A" --port 4098 --tunnel --front --execute
64
+ as_user node "$HERE/configure-proof-agent.mjs" "$B" --port 4097 --front --execute
65
+ in_session "$INSTAR" server restart --dir "$A" || in_session "$INSTAR" server start --dir "$A"
66
+ in_session "$INSTAR" server restart --dir "$B" || in_session "$INSTAR" server start --dir "$B"
67
+ ;;
68
+ esac