@stravica-ai/digital-operator-pa 0.1.56 → 0.1.57

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,7 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/engineering-pa-cli/src/operator-attendance/cli.mjs')
3
+ .then(async (m) => process.exit(await m.main(process.argv.slice(2))))
4
+ .catch((err) => {
5
+ process.stderr.write(`pa-operator-attendance: fatal error loading hook (${err && err.message}); failing open\n`);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/engineering-pa-cli/src/worker-launcher/cli.mjs')
3
+ .then(async (m) => process.exit(await m.main(process.argv.slice(2))))
4
+ .catch((err) => {
5
+ process.stderr.write(`pa-worker-launcher: fatal error (${err && err.message})\n`);
6
+ process.exit(2);
7
+ });
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // pa-operator-attendance - operator-presence hook for Claude Code.
3
+ //
4
+ // Two subcommands wired into the scaffolded .claude/settings.json
5
+ // (session-start, pre-tool-use). See src/operator-attendance/cli.mjs
6
+ // for the behaviour, the exit-code contract (hook contract, not the
7
+ // repo's own 0/1/2/3), and the observe-vs-enforce switch.
8
+ //
9
+ // Fail-closed is handled inside main(); this top-level .catch() exists
10
+ // for the pathological case where even the dynamic import() fails
11
+ // (corrupted install). The hook contract reads an unexpected exit code
12
+ // as non-blocking, so a partial install does not block every tool call
13
+ // estate-wide: exit 1 and log, same as the inner malformed-input case.
14
+ import('../src/operator-attendance/cli.mjs')
15
+ .then(async (m) => process.exit(await m.main(process.argv.slice(2))))
16
+ .catch((err) => {
17
+ process.stderr.write(`pa-operator-attendance: fatal error loading hook (${err && err.message}); failing open\n`);
18
+ process.exit(1);
19
+ });
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // pa-worker-launcher - mint a signed PA_WORKER_GRANT and spawn an
3
+ // unattended `claude -p` child session for a dispatch. See
4
+ // src/worker-launcher/cli.mjs for the full behaviour and exit-code
5
+ // contract (CONTRIBUTING.md: 0 ok / 1 usage / 2 operational / 3 IO).
6
+ //
7
+ // The hook contract covers the SessionStart + PreToolUse legs; this
8
+ // bin is a normal estate CLI and uses the standard exit-code contract.
9
+ import('../src/worker-launcher/cli.mjs')
10
+ .then(async (m) => process.exit(await m.main(process.argv.slice(2))))
11
+ .catch((err) => {
12
+ process.stderr.write(`pa-worker-launcher: fatal error (${err && err.message})\n`);
13
+ process.exit(2);
14
+ });
@@ -75,6 +75,7 @@
75
75
  "hardDeny": {
76
76
  "_comment": "Checked before any allow pattern (core or overlay), for both Bash commands and Read/Write paths. Overlays cannot add, remove, or shadow entries here — see policy.mjs. Not a security sandbox (a determined caller can still smuggle a second command past a naive prefix regex — see bashChainGuard below for the mitigation), just a mechanical backstop against the most obviously destructive one-liners, the specific 'obviously-destructive gh verbs' the spec calls out, and reads/writes of credential material even if some overlay pattern would otherwise match it.",
77
77
  "bashPatterns": [
78
+ "^\\s*claude(\\s+[^-\\s]|\\s*$)",
78
79
  "rm\\s+-rf\\b",
79
80
  "curl[^\\n]*\\|\\s*(sh|bash|zsh)\\b",
80
81
  "wget[^\\n]*\\|\\s*(sh|bash|zsh)\\b",
@@ -93,6 +94,7 @@
93
94
  ],
94
95
  "bashChainGuardPattern": "[;&|`<>\\n]|\\$\\(",
95
96
  "bashChainGuardNote": "Tier2 Bash commands containing a shell-chaining metacharacter (;, &&, ||, |, backtick, $(...), <, >) or an embedded newline are blocked outright even if the leading command (or leading line) matches an allow pattern — a prefix match on 'pa knowledge search foo && rm -rf ~' must not slip through, nor must 'pa knowledge search foo\\nsudo apt-get purge -y openssh-server' or a redirect like 'echo foo > ~/.ssh/authorized_keys'. False positives (e.g. a quoted '&&' inside a search string) are an accepted cost; the hook always suggests a dispatch alternative.",
97
+ "claudeLaunchNote": "The leading `^\\s*claude(\\s+[^-\\s]|\\s*$)` entry denies raw agent-CLI launches from Bash: bare `claude`, `claude help foo`, `claude -p <...>`, `claude mcp list`, any `claude <word>` form that would start a full session that boots the project PA. Flag-only inspection forms (`claude --help`, `claude --version`, `claude --mcp-list`, `claude -v`, `claude -h`) pass through because the character after the space is `-`. A dispatch that genuinely needs its own child session goes through pa-worker-launcher, which carries a signed PA_WORKER_GRANT; the pa-operator-attendance PreToolUse hook enforces the same deny with the launcher-grant exemption.",
96
98
  "pathPatterns": [
97
99
  "\\.vault/",
98
100
  "secrets\\.enc\\.json$",
@@ -17,7 +17,9 @@
17
17
  "smoke": "bin/smoke",
18
18
  "launch": "bin/launch",
19
19
  "research": "bin/research",
20
- "pa-coordinator-hook": "bin/pa-coordinator-hook"
20
+ "pa-coordinator-hook": "bin/pa-coordinator-hook",
21
+ "pa-operator-attendance": "bin/pa-operator-attendance",
22
+ "pa-worker-launcher": "bin/pa-worker-launcher"
21
23
  },
22
24
  "dependencies": {
23
25
  "@stravica-ai/engineering-pa-core": "workspace:*",
@@ -0,0 +1,420 @@
1
+ // cli.mjs - pa-operator-attendance entry point.
2
+ //
3
+ // Two subcommands run from one program:
4
+ //
5
+ // `pa-operator-attendance session-start`
6
+ // Wired into Claude Code's SessionStart hook. Computes the attendance
7
+ // stamp (TTY + env + no grant), persists it to a per-session cache,
8
+ // and emits a one-line `additionalContext` block carrying
9
+ // `operator-present: true|false[...]` for the loaded agent to read as
10
+ // the first line of its workState context.
11
+ //
12
+ // `pa-operator-attendance pre-tool-use`
13
+ // Wired into Claude Code's PreToolUse hook, matcher Bash + Edit +
14
+ // Write + NotebookEdit. Reads the attendance stamp (recomputing if
15
+ // the cache is missing), denies raw `claude <word>` launches
16
+ // regardless of attendance, denies estate-mutating tool calls on an
17
+ // unattended session with no grant, and otherwise falls through.
18
+ //
19
+ // Observe-vs-enforce switch: `PA_OPERATOR_ATTENDANCE_MODE` env var.
20
+ // observe (default): every would-deny is appended as one JSONL line
21
+ // to the decision log; the hook exits 0 and the call proceeds. Used
22
+ // for one point release before the default flips to enforce so both
23
+ // estates get a week of real-traffic decisions before the gate goes
24
+ // live.
25
+ // enforce: would-denies become real exit-2 denies with stderr
26
+ // messages the model sees.
27
+ //
28
+ // Exit codes follow Claude Code's hook contract, not the repo's own
29
+ // 0/1/2/3 CLI convention: 0 = allow / stamp emitted, 2 = block (enforce),
30
+ // 1 = non-blocking error (malformed input, unexpected failure).
31
+ //
32
+ // Zero dependencies beyond node stdlib and the shared grant module in
33
+ // `@stravica-ai/engineering-pa-core/src/attendance/grant.mjs`.
34
+
35
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
36
+ import { dirname, join } from 'node:path';
37
+ import { tmpdir } from 'node:os';
38
+ import {
39
+ loadOrMintHostKey,
40
+ verifyGrant,
41
+ resolveKeyPath,
42
+ } from '../../../engineering-pa-core/src/attendance/grant.mjs';
43
+ import { resolveHome } from '../../../engineering-pa-core/src/home/resolve.mjs';
44
+
45
+ const USAGE = `pa-operator-attendance: operator-presence gate for Claude Code hooks.
46
+
47
+ Two subcommands wired into the scaffolded .claude/settings.json:
48
+
49
+ session-start SessionStart hook: stamp attendance, emit the
50
+ operator-present line, cache the stamp for the
51
+ PreToolUse leg.
52
+ pre-tool-use PreToolUse hook (Bash + Edit + Write + NotebookEdit):
53
+ deny raw agent-CLI launches, deny estate-mutating
54
+ calls on an unattended session without a grant.
55
+
56
+ Environment:
57
+ PA_OPERATOR_ATTENDANCE_MODE=observe|enforce default: observe
58
+ PA_WORKER_GRANT=<dispatchId>.<unixTs>.<hmac> minted by pa-worker-launcher
59
+ PA_ATTENDANCE_KEY_PATH=/path/to/key override host-key location
60
+ PA_ATTENDANCE_LOG_DIR=/path/to/log/dir override decision-log dir
61
+
62
+ Decision log (observe mode): one JSONL line per would-deny at
63
+ <logDir>/attendance-decisions.jsonl. Rejection log: a hostile grant lands
64
+ at <logDir>/attendance-rejects.log.
65
+
66
+ Usage:
67
+ pa-operator-attendance session-start
68
+ pa-operator-attendance pre-tool-use
69
+ pa-operator-attendance --help
70
+ `;
71
+
72
+ const RAW_CLAUDE_PATTERN = /^\s*claude(\s+[^-\s]|\s*$)/;
73
+
74
+ // Bash commands denied for an unattended + no-grant session. Any line
75
+ // matching one of these is a durable estate side effect: a commit, a
76
+ // push, a write through the pa-family CLI, a redirect, a tee, an rm.
77
+ // Read-only verbs (git status, git log, ls, cat, grep, etc.) are not
78
+ // listed and pass through.
79
+ const UNATTENDED_MUTATE_PATTERNS = [
80
+ /^\s*git\s+(commit|push|merge|rebase|cherry-pick|reset|checkout|branch|tag|stash|am|revert|pull)\b/,
81
+ /^\s*gh\s+(pr\s+(create|edit|merge|close|comment|review)|repo\s+(create|edit|delete|archive)|issue\s+(create|edit|close|comment)|release\s+(create|edit|delete)|workflow\s+(enable|disable|run)|secret\s+(set|remove))\b/,
82
+ /^\s*(pnpm\s+exec\s+)?pa\s+(work|project|library|relay|autonomy|content-pub|secrets|scheduler)\s+(?!(?:help|list|ls|get|show|view|status|tail|find|search|export|path|inspect|whoami|context|overview|today|related|domain|orphans|read|print))/,
83
+ /^\s*(pnpm\s+exec\s+)?library\s+(put|flush|refresh-clones|up-merge)\b/,
84
+ /^\s*(pnpm\s+exec\s+)?claude-relay\s+(send|publish|post)\b/,
85
+ /^\s*rm\b/,
86
+ /^\s*mv\b/,
87
+ /^\s*cp\b/,
88
+ /^\s*mkdir\b/,
89
+ /^\s*touch\b/,
90
+ /^\s*(ln|chmod|chown)\b/,
91
+ /^\s*(tee|dd)\b/,
92
+ /^\s*(apt|apt-get|brew|npm|pnpm|yarn)\s+(install|add|remove|uninstall|upgrade|update|publish)\b/,
93
+ /[^<>]>\s*\S/, // `foo > bar`, `foo 2> bar`: any single-redirect write
94
+ />>\s*\S/, // append redirect
95
+ ];
96
+
97
+ export async function main(argv = [], {
98
+ stdin = process.stdin,
99
+ stdout = process.stdout,
100
+ stderr = process.stderr,
101
+ env = process.env,
102
+ now = () => new Date(),
103
+ } = {}) {
104
+ if (argv.includes('-h') || argv.includes('--help')) {
105
+ stderr.write(USAGE);
106
+ return 0;
107
+ }
108
+ const sub = argv[0];
109
+ if (!sub) {
110
+ stderr.write('pa-operator-attendance: missing subcommand (session-start | pre-tool-use)\n');
111
+ return 1;
112
+ }
113
+ try {
114
+ if (sub === 'session-start') {
115
+ return await runSessionStart({ stdin, stdout, stderr, env, now });
116
+ }
117
+ if (sub === 'pre-tool-use') {
118
+ return await runPreToolUse({ stdin, stdout, stderr, env, now });
119
+ }
120
+ stderr.write(`pa-operator-attendance: unknown subcommand "${sub}"\n`);
121
+ return 1;
122
+ } catch (err) {
123
+ // A hook that throws unhandled is worse than a degraded one: a
124
+ // SessionStart failure blocks the boot, a PreToolUse failure blocks
125
+ // every tool call. Exit 1 (non-blocking per the hooks contract) and
126
+ // leave a stderr note the transcript will pick up.
127
+ stderr.write(`pa-operator-attendance: ${sub || 'unknown'}: unexpected error (${err && err.message}); failing open\n`);
128
+ return 1;
129
+ }
130
+ }
131
+
132
+ // --- session-start ---------------------------------------------------------
133
+
134
+ async function runSessionStart({ stdin, stdout, stderr, env, now }) {
135
+ const payload = await readPayload(stdin);
136
+ // SessionStart has no required fields we gate on; we still parse it so
137
+ // a payload with `session_id` can key the cache properly.
138
+ const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
139
+
140
+ // Attendance detection. The empirical probe for w-2026-10-02-dave-018
141
+ // (session-start leg) proved against client 2.1.287 that:
142
+ // - a `claude -p` child always sees CLAUDE_CODE_SESSION_ATTENDED=0
143
+ // (not unset; the client zeroes it explicitly)
144
+ // - a SessionStart hook fired by a `claude -p` child sees both
145
+ // stdin and stdout as non-TTY
146
+ // So the positive-proof gate is: stdout.isTTY AND env === '1' AND no
147
+ // grant. Any other combination is unattended, which is the fail-safe
148
+ // direction (a client bump that strips the var defaults to absent).
149
+ const grantPresent = typeof env.PA_WORKER_GRANT === 'string' && env.PA_WORKER_GRANT.length > 0;
150
+ const envAttended = env.CLAUDE_CODE_SESSION_ATTENDED === '1';
151
+ const stdoutIsTty = Boolean(stdout.isTTY);
152
+ const attended = stdoutIsTty && envAttended && !grantPresent;
153
+
154
+ let stamp;
155
+ if (attended) {
156
+ stamp = { operatorPresent: true, grant: 'none', scope: 'attended' };
157
+ } else if (grantPresent) {
158
+ const key = await loadOrMintHostKeySafe(env);
159
+ const verdict = key
160
+ ? verifyGrant({ grant: env.PA_WORKER_GRANT, hostKey: key, now: now() })
161
+ : { ok: false, reason: 'host-key-unavailable' };
162
+ if (verdict.ok) {
163
+ stamp = {
164
+ operatorPresent: false,
165
+ grant: verdict.dispatchId,
166
+ scope: 'dispatch-only',
167
+ grantUnixTs: verdict.unixTs,
168
+ };
169
+ } else {
170
+ stamp = {
171
+ operatorPresent: false,
172
+ grant: 'rejected',
173
+ scope: 'refusal',
174
+ refusal: `grant ${verdict.reason}`,
175
+ };
176
+ await appendRejectLog({ env, sessionId, reason: verdict.reason, now: now() });
177
+ }
178
+ } else {
179
+ stamp = {
180
+ operatorPresent: false,
181
+ grant: 'none',
182
+ scope: 'refusal',
183
+ refusal: 'boot-routine denied, no PA_WORKER_GRANT',
184
+ };
185
+ }
186
+
187
+ await persistStamp({ env, sessionId, stamp, now: now() });
188
+
189
+ // Emit the Claude Code hook JSON envelope. The additionalContext text
190
+ // becomes the first line of the session's workState context, carrying
191
+ // the stamp to any downstream hook (boot-context, relay list) and to
192
+ // the loaded agent per the canon clause.
193
+ const contextLine = formatStampLine(stamp);
194
+ const envelope = {
195
+ hookSpecificOutput: {
196
+ hookEventName: 'SessionStart',
197
+ additionalContext: contextLine + '\n',
198
+ },
199
+ };
200
+ stdout.write(JSON.stringify(envelope) + '\n');
201
+
202
+ // Observe-mode trace: every session-start decision lands as one JSONL
203
+ // line so operators can see how the gate would have behaved on the
204
+ // first point release before the default flips to enforce.
205
+ await appendDecisionLog({
206
+ env,
207
+ now: now(),
208
+ leg: 'session-start',
209
+ decision: 'stamp',
210
+ stamp,
211
+ sessionId,
212
+ attendedSignals: { stdoutIsTty, envAttended, grantPresent },
213
+ });
214
+ return 0;
215
+ }
216
+
217
+ function formatStampLine(stamp) {
218
+ const parts = [`operator-present: ${stamp.operatorPresent ? 'true' : 'false'}`];
219
+ if (stamp.grant) parts.push(`grant: ${stamp.grant}`);
220
+ if (stamp.scope) parts.push(`scope: ${stamp.scope}`);
221
+ if (stamp.refusal) parts.push(`refusal: ${stamp.refusal}`);
222
+ return parts.join(', ');
223
+ }
224
+
225
+ // --- pre-tool-use ----------------------------------------------------------
226
+
227
+ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
228
+ const mode = env.PA_OPERATOR_ATTENDANCE_MODE === 'enforce' ? 'enforce' : 'observe';
229
+ let payload;
230
+ try {
231
+ payload = await readPayload(stdin);
232
+ } catch (err) {
233
+ stderr.write(`pa-operator-attendance: pre-tool-use: malformed stdin (${err.message}); failing open\n`);
234
+ return 1;
235
+ }
236
+ const toolName = payload && typeof payload.tool_name === 'string' ? payload.tool_name : '';
237
+ const toolInput = (payload && payload.tool_input) || {};
238
+ if (!toolName) {
239
+ stderr.write('pa-operator-attendance: pre-tool-use: payload missing tool_name; failing open\n');
240
+ return 1;
241
+ }
242
+ const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
243
+ const stamp = await readStamp({ env, sessionId }) || recomputeStamp(env);
244
+
245
+ const bashCommand = toolName === 'Bash' && typeof toolInput.command === 'string'
246
+ ? toolInput.command
247
+ : null;
248
+
249
+ // Rule 1: raw agent-CLI launch is denied regardless of attendance.
250
+ // The flag-only forms (`claude --help`, `claude --version`, `claude -v`,
251
+ // `claude -h`) pass; everything else (`claude help foo`, `claude -p`,
252
+ // `claude mcp list`, bare `claude`) is a session launch and must go
253
+ // through the launcher.
254
+ if (bashCommand) {
255
+ for (const line of bashCommand.split('\n')) {
256
+ if (RAW_CLAUDE_PATTERN.test(line)) {
257
+ return await denyOrObserve({
258
+ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand,
259
+ stamp, rule: 'raw-claude-launch', matched: line.trim(),
260
+ message: 'pa-operator-attendance: raw agent-CLI launch denied. A `claude <word>` from a tool call starts a full session that boots the project PA. Use pa-worker-launcher to spawn a child session with a dispatch grant, or an inspection flag form (`claude --help`, `claude --version`).',
261
+ });
262
+ }
263
+ }
264
+ }
265
+
266
+ // Rule 2: unattended + no grant denies every estate-mutating call.
267
+ if (!stamp.operatorPresent && stamp.grant === 'none') {
268
+ if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
269
+ return await denyOrObserve({
270
+ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand: null,
271
+ stamp, rule: 'unattended-write', matched: toolName,
272
+ message: `pa-operator-attendance: ${toolName} denied; session is unattended with no PA_WORKER_GRANT; estate side effects are blocked. Allowed: Read, Grep, Glob, and flag-only CLI inspection.`,
273
+ });
274
+ }
275
+ if (bashCommand) {
276
+ for (const line of bashCommand.split('\n')) {
277
+ for (const pattern of UNATTENDED_MUTATE_PATTERNS) {
278
+ if (pattern.test(line)) {
279
+ return await denyOrObserve({
280
+ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand,
281
+ stamp, rule: 'unattended-mutate', matched: line.trim(),
282
+ message: `pa-operator-attendance: Bash command denied; session is unattended with no PA_WORKER_GRANT; estate side effects are blocked (matched "${pattern.source}"). Allowed: read-only inspection.`,
283
+ });
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ // Rule 2a: a rejected grant is hostile, not merely absent. Any tool
290
+ // call on a session with grant: rejected is denied outright until the
291
+ // operator attends or re-mints the grant.
292
+ if (!stamp.operatorPresent && stamp.grant === 'rejected') {
293
+ return await denyOrObserve({
294
+ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand,
295
+ stamp, rule: 'rejected-grant', matched: toolName,
296
+ message: `pa-operator-attendance: ${toolName} denied; session carried a PA_WORKER_GRANT that failed verification; every tool call is blocked until the session attends or re-mints a valid grant.`,
297
+ });
298
+ }
299
+ return 0;
300
+ }
301
+
302
+ function recomputeStamp(env) {
303
+ // Fallback when the session-start cache is missing (hook ordering, cold
304
+ // start). Mirror the session-start detection without the key material,
305
+ // which means a grant that LOOKS valid shape-wise is treated as
306
+ // dispatch-only without HMAC verification. Intentional: the hook stays
307
+ // closed if session-start's richer stamp was lost, but it does not
308
+ // fabricate a verdict it does not have evidence for.
309
+ const grantPresent = typeof env.PA_WORKER_GRANT === 'string' && env.PA_WORKER_GRANT.length > 0;
310
+ const envAttended = env.CLAUDE_CODE_SESSION_ATTENDED === '1';
311
+ // In the pre-tool-use leg, stdout is always the hook pipe, so TTY check
312
+ // alone is unreliable. Trust the env signal only when there is no grant.
313
+ if (grantPresent) return { operatorPresent: false, grant: 'present', scope: 'dispatch-only' };
314
+ if (envAttended) return { operatorPresent: true, grant: 'none', scope: 'attended' };
315
+ return { operatorPresent: false, grant: 'none', scope: 'refusal', refusal: 'no stamp cache, env signals absent' };
316
+ }
317
+
318
+ async function denyOrObserve({ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand, stamp, rule, matched, message }) {
319
+ await appendDecisionLog({
320
+ env,
321
+ now: now(),
322
+ leg: 'pre-tool-use',
323
+ decision: mode === 'enforce' ? 'deny' : 'observe',
324
+ rule,
325
+ matched,
326
+ toolName,
327
+ bashCommand,
328
+ stamp,
329
+ sessionId,
330
+ mode,
331
+ });
332
+ if (mode === 'enforce') {
333
+ stderr.write(message + '\n');
334
+ return 2;
335
+ }
336
+ // Observe mode: write a one-line stderr note so the operator can see
337
+ // what would have been denied without the tool call being blocked.
338
+ stderr.write(`pa-operator-attendance: OBSERVE would deny (${rule}: ${matched})\n`);
339
+ return 0;
340
+ }
341
+
342
+ // --- helpers ---------------------------------------------------------------
343
+
344
+ async function readPayload(stdin) {
345
+ if (stdin.isTTY) return {};
346
+ const chunks = [];
347
+ for await (const chunk of stdin) chunks.push(chunk);
348
+ if (chunks.length === 0) return {};
349
+ const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString('utf8');
350
+ if (raw.trim() === '') return {};
351
+ return JSON.parse(raw);
352
+ }
353
+
354
+ async function loadOrMintHostKeySafe(env) {
355
+ try {
356
+ return await loadOrMintHostKey({ keyPath: resolveKeyPath({ env }) });
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ function resolveLogDir(env) {
363
+ if (env.PA_ATTENDANCE_LOG_DIR && env.PA_ATTENDANCE_LOG_DIR.length > 0) {
364
+ return env.PA_ATTENDANCE_LOG_DIR;
365
+ }
366
+ return join(resolveHome({ env }), '.local', 'share', 'pa');
367
+ }
368
+
369
+ function resolveStampPath(env, sessionId) {
370
+ const base = env.TMPDIR || tmpdir();
371
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
372
+ return join(base, `pa-attendance-${safe}.json`);
373
+ }
374
+
375
+ async function persistStamp({ env, sessionId, stamp, now }) {
376
+ const path = resolveStampPath(env, sessionId);
377
+ const record = { ...stamp, writtenAt: now.toISOString(), sessionId };
378
+ try {
379
+ await mkdir(dirname(path), { recursive: true });
380
+ await writeFile(path, JSON.stringify(record), { mode: 0o600 });
381
+ } catch {
382
+ // The cache is a convenience for the pre-tool-use leg; losing it
383
+ // means the leg falls back to env recomputation (fail-safe).
384
+ }
385
+ }
386
+
387
+ async function readStamp({ env, sessionId }) {
388
+ const path = resolveStampPath(env, sessionId);
389
+ try {
390
+ const raw = await readFile(path, 'utf8');
391
+ return JSON.parse(raw);
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ async function appendDecisionLog({ env, now, ...fields }) {
398
+ const dir = resolveLogDir(env);
399
+ const path = join(dir, 'attendance-decisions.jsonl');
400
+ const line = JSON.stringify({ ts: now.toISOString(), ...fields }) + '\n';
401
+ try {
402
+ await mkdir(dir, { recursive: true });
403
+ await appendFile(path, line);
404
+ } catch {
405
+ // Decision log is best effort; a hook never fails on logging.
406
+ }
407
+ }
408
+
409
+ async function appendRejectLog({ env, sessionId, reason, now }) {
410
+ const dir = resolveLogDir(env);
411
+ const path = join(dir, 'attendance-rejects.log');
412
+ const line = `${now.toISOString()} session=${sessionId} reason=${reason}\n`;
413
+ try {
414
+ await mkdir(dir, { recursive: true });
415
+ await appendFile(path, line);
416
+ } catch {
417
+ // Reject log is best effort too; the stamp is already emitting a
418
+ // refusal downstream.
419
+ }
420
+ }