@stravica-ai/digital-operator-pa 0.1.57 → 0.1.58
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.
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// `pa-operator-attendance session-start`
|
|
6
6
|
// Wired into Claude Code's SessionStart hook. Computes the attendance
|
|
7
|
-
// stamp (
|
|
8
|
-
// and emits a one-line `additionalContext` block
|
|
9
|
-
// `operator-present: true|false[...]` for the loaded agent
|
|
10
|
-
// the first line of its workState context.
|
|
7
|
+
// stamp (env + parent-process TTY + no grant), persists it to a
|
|
8
|
+
// per-session cache, and emits a one-line `additionalContext` block
|
|
9
|
+
// carrying `operator-present: true|false[...]` for the loaded agent
|
|
10
|
+
// to read as the first line of its workState context.
|
|
11
11
|
//
|
|
12
12
|
// `pa-operator-attendance pre-tool-use`
|
|
13
13
|
// Wired into Claude Code's PreToolUse hook, matcher Bash + Edit +
|
|
@@ -31,10 +31,33 @@
|
|
|
31
31
|
//
|
|
32
32
|
// Zero dependencies beyond node stdlib and the shared grant module in
|
|
33
33
|
// `@stravica-ai/engineering-pa-core/src/attendance/grant.mjs`.
|
|
34
|
+
//
|
|
35
|
+
// Attendance detection (0.1.58 fix for w-2026-10-02-dave-044, from Dex's
|
|
36
|
+
// 0.1.57 review): the hook's own stdout is always a pipe (the client
|
|
37
|
+
// reads the JSON envelope), so stdout.isTTY cannot discriminate. The
|
|
38
|
+
// discriminator is the hook's PARENT process: in an attended session
|
|
39
|
+
// the parent is the claude client attached to the operator's terminal
|
|
40
|
+
// (ps reports a real tty, e.g. `ttys000`); in a `claude -p` worker or a
|
|
41
|
+
// `claude --print` child the parent is a bash shell under a Bash tool
|
|
42
|
+
// with no controlling tty (ps reports `??`). We therefore probe
|
|
43
|
+
// `ps -o tty= -p <ppid>` and treat a non-empty, non-`?`/non-`??` result
|
|
44
|
+
// as a real tty. On win32 ps is absent, so we fall back to the env var
|
|
45
|
+
// alone and record `ttyProbe: 'unavailable'` on the stamp so the
|
|
46
|
+
// limitation is visible in the decision log.
|
|
47
|
+
//
|
|
48
|
+
// Pattern un-anchoring and log redaction (0.1.58 fixes B and C): the
|
|
49
|
+
// raw-claude and unattended-mutate regexes tolerate any number of
|
|
50
|
+
// leading `NAME=value` (and `env NAME=value`) prefixes so a line like
|
|
51
|
+
// `GITHUB_TOKEN=x gh pr create` still matches, and the Bash command is
|
|
52
|
+
// run through `redactBashCommand` before it lands in the decision log,
|
|
53
|
+
// stripping values of `NAME=value` prefixes, `Authorization:` header
|
|
54
|
+
// values, `--token`/`token=` style arguments, and
|
|
55
|
+
// `https://user:secret@` URL credentials.
|
|
34
56
|
|
|
35
57
|
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
36
58
|
import { dirname, join } from 'node:path';
|
|
37
59
|
import { tmpdir } from 'node:os';
|
|
60
|
+
import { spawnSync } from 'node:child_process';
|
|
38
61
|
import {
|
|
39
62
|
loadOrMintHostKey,
|
|
40
63
|
verifyGrant,
|
|
@@ -60,8 +83,8 @@ Environment:
|
|
|
60
83
|
PA_ATTENDANCE_LOG_DIR=/path/to/log/dir override decision-log dir
|
|
61
84
|
|
|
62
85
|
Decision log (observe mode): one JSONL line per would-deny at
|
|
63
|
-
<logDir>/attendance-decisions.jsonl
|
|
64
|
-
at <logDir>/attendance-rejects.log.
|
|
86
|
+
<logDir>/attendance-decisions.jsonl, with Bash commands redacted. A
|
|
87
|
+
hostile grant lands at <logDir>/attendance-rejects.log.
|
|
65
88
|
|
|
66
89
|
Usage:
|
|
67
90
|
pa-operator-attendance session-start
|
|
@@ -69,37 +92,116 @@ Usage:
|
|
|
69
92
|
pa-operator-attendance --help
|
|
70
93
|
`;
|
|
71
94
|
|
|
72
|
-
|
|
95
|
+
// Env-prefix tolerance: zero or more `NAME=value` tokens (optionally
|
|
96
|
+
// preceded by `env`) before the command word.
|
|
97
|
+
const ENV_PREFIX = String.raw`(?:(?:env\s+)?[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*`;
|
|
98
|
+
|
|
99
|
+
const RAW_CLAUDE_PATTERN = new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`claude(\s+[^-\s]|\s*$)`);
|
|
73
100
|
|
|
74
101
|
// Bash commands denied for an unattended + no-grant session. Any line
|
|
75
102
|
// matching one of these is a durable estate side effect: a commit, a
|
|
76
103
|
// push, a write through the pa-family CLI, a redirect, a tee, an rm.
|
|
77
104
|
// Read-only verbs (git status, git log, ls, cat, grep, etc.) are not
|
|
78
|
-
// listed and pass through.
|
|
105
|
+
// listed and pass through. Each pattern begins with `^\s*${ENV_PREFIX}`
|
|
106
|
+
// so a leading `GITHUB_TOKEN=x` or `env NAME=value` does not bypass the
|
|
107
|
+
// rule.
|
|
79
108
|
const UNATTENDED_MUTATE_PATTERNS = [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
109
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`git\s+(commit|push|merge|rebase|cherry-pick|reset|checkout|branch|tag|stash|am|revert|pull)\b`),
|
|
110
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`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`),
|
|
111
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`(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))`),
|
|
112
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`(pnpm\s+exec\s+)?library\s+(put|flush|refresh-clones|up-merge)\b`),
|
|
113
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`(pnpm\s+exec\s+)?claude-relay\s+(send|publish|post)\b`),
|
|
114
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`rm\b`),
|
|
115
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`mv\b`),
|
|
116
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`cp\b`),
|
|
117
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`mkdir\b`),
|
|
118
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`touch\b`),
|
|
119
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`(ln|chmod|chown)\b`),
|
|
120
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`(tee|dd)\b`),
|
|
121
|
+
new RegExp(String.raw`^\s*` + ENV_PREFIX + String.raw`(apt|apt-get|brew|npm|pnpm|yarn)\s+(install|add|remove|uninstall|upgrade|update|publish)\b`),
|
|
93
122
|
/[^<>]>\s*\S/, // `foo > bar`, `foo 2> bar`: any single-redirect write
|
|
94
123
|
/>>\s*\S/, // append redirect
|
|
95
124
|
];
|
|
96
125
|
|
|
126
|
+
// Default TTY probe: ask `ps` for the controlling TTY of the hook's
|
|
127
|
+
// parent process. Returns `null` on no-tty (`??` / `?` / empty) or
|
|
128
|
+
// probe error, a non-empty TTY name (e.g. `ttys000`, `pts/0`) when the
|
|
129
|
+
// parent has one, and `'unavailable'` on win32 (where `ps` is absent).
|
|
130
|
+
// Fail-safe: any error path returns `null` so the detection drops to
|
|
131
|
+
// "unattended", which never grants writes it should not.
|
|
132
|
+
function defaultTtyProbe({ ppid, platform }) {
|
|
133
|
+
if (platform === 'win32') return 'unavailable';
|
|
134
|
+
if (!Number.isInteger(ppid) || ppid <= 0) return null;
|
|
135
|
+
try {
|
|
136
|
+
const result = spawnSync('ps', ['-o', 'tty=', '-p', String(ppid)], {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
timeout: 2000,
|
|
139
|
+
});
|
|
140
|
+
if (result.error || result.status !== 0) return null;
|
|
141
|
+
const tty = (result.stdout || '').trim();
|
|
142
|
+
if (!tty || tty === '?' || tty === '??') return null;
|
|
143
|
+
return tty;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve the detection: returns { attended, parentTty, envAttended,
|
|
150
|
+
// grantPresent }. The attended gate is positive-proof: env === '1' AND
|
|
151
|
+
// a real controlling TTY on the parent process AND no grant. On a
|
|
152
|
+
// platform where the TTY probe is unavailable (win32), fall back to the
|
|
153
|
+
// env var alone so a Windows operator can still mark the session
|
|
154
|
+
// attended; the stamp records `ttyProbe: 'unavailable'` so the
|
|
155
|
+
// limitation is visible in the decision log.
|
|
156
|
+
function resolveAttendance({ env, ttyProbe, platform, ppid }) {
|
|
157
|
+
const envAttended = env.CLAUDE_CODE_SESSION_ATTENDED === '1';
|
|
158
|
+
const grantPresent = typeof env.PA_WORKER_GRANT === 'string' && env.PA_WORKER_GRANT.length > 0;
|
|
159
|
+
const parentTty = ttyProbe({ ppid, platform, env });
|
|
160
|
+
let attended;
|
|
161
|
+
if (parentTty === 'unavailable') {
|
|
162
|
+
attended = envAttended && !grantPresent;
|
|
163
|
+
} else {
|
|
164
|
+
attended = envAttended && typeof parentTty === 'string' && parentTty.length > 0 && !grantPresent;
|
|
165
|
+
}
|
|
166
|
+
return { attended, parentTty, envAttended, grantPresent };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Redact a Bash command before writing it into the decision log or any
|
|
170
|
+
// stderr trace line. Values redacted:
|
|
171
|
+
// 1. `NAME=value` and `env NAME=value` prefixes (and inline forms):
|
|
172
|
+
// the key is kept, the value is replaced with `<redacted>`.
|
|
173
|
+
// 2. `Authorization: Bearer ...` and `Authorization: ...` header
|
|
174
|
+
// values.
|
|
175
|
+
// 3. `--token <x>`, `--token=<x>`, `token=<x>` argument-borne tokens.
|
|
176
|
+
// 4. `https://user:secret@host` URL credentials: user kept, secret
|
|
177
|
+
// replaced.
|
|
178
|
+
export function redactBashCommand(cmd) {
|
|
179
|
+
if (typeof cmd !== 'string') return cmd;
|
|
180
|
+
let out = cmd;
|
|
181
|
+
// user:secret@host URL credential (do this BEFORE the KEY=VALUE pass
|
|
182
|
+
// so the scheme:// does not accidentally feed the key regex).
|
|
183
|
+
out = out.replace(/(https?:\/\/[^:/\s@]+):([^@\s'"`]+)@/gi, '$1:<redacted>@');
|
|
184
|
+
// Authorization header value (quoted or unquoted, Bearer or raw).
|
|
185
|
+
out = out.replace(/(Authorization\s*:\s*)(?:Bearer\s+)?([^\s'"`,;)]+)/gi, '$1<redacted>');
|
|
186
|
+
// --token / --token= and token=
|
|
187
|
+
out = out.replace(/(--?token[\s=]+)([^\s'"`;)]+)/gi, '$1<redacted>');
|
|
188
|
+
// NAME=value (env-style). Match a word boundary that is not preceded
|
|
189
|
+
// by `-` or `=` so `--foo=bar` and `=key=val` do not re-match the
|
|
190
|
+
// trailing key.
|
|
191
|
+
out = out.replace(/(^|[\s;&|`(])((?:env\s+)?)([A-Za-z_][A-Za-z0-9_]*)=([^\s'"`]+)/g,
|
|
192
|
+
(_m, lead, envWord, key, _val) => `${lead}${envWord}${key}=<redacted>`);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
97
196
|
export async function main(argv = [], {
|
|
98
197
|
stdin = process.stdin,
|
|
99
198
|
stdout = process.stdout,
|
|
100
199
|
stderr = process.stderr,
|
|
101
200
|
env = process.env,
|
|
102
201
|
now = () => new Date(),
|
|
202
|
+
ttyProbe = defaultTtyProbe,
|
|
203
|
+
platform = process.platform,
|
|
204
|
+
ppid = process.ppid,
|
|
103
205
|
} = {}) {
|
|
104
206
|
if (argv.includes('-h') || argv.includes('--help')) {
|
|
105
207
|
stderr.write(USAGE);
|
|
@@ -112,10 +214,10 @@ export async function main(argv = [], {
|
|
|
112
214
|
}
|
|
113
215
|
try {
|
|
114
216
|
if (sub === 'session-start') {
|
|
115
|
-
return await runSessionStart({ stdin, stdout, stderr, env, now });
|
|
217
|
+
return await runSessionStart({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid });
|
|
116
218
|
}
|
|
117
219
|
if (sub === 'pre-tool-use') {
|
|
118
|
-
return await runPreToolUse({ stdin, stdout, stderr, env, now });
|
|
220
|
+
return await runPreToolUse({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid });
|
|
119
221
|
}
|
|
120
222
|
stderr.write(`pa-operator-attendance: unknown subcommand "${sub}"\n`);
|
|
121
223
|
return 1;
|
|
@@ -131,25 +233,13 @@ export async function main(argv = [], {
|
|
|
131
233
|
|
|
132
234
|
// --- session-start ---------------------------------------------------------
|
|
133
235
|
|
|
134
|
-
async function runSessionStart({ stdin, stdout, stderr, env, now }) {
|
|
236
|
+
async function runSessionStart({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid }) {
|
|
135
237
|
const payload = await readPayload(stdin);
|
|
136
238
|
// SessionStart has no required fields we gate on; we still parse it so
|
|
137
239
|
// a payload with `session_id` can key the cache properly.
|
|
138
240
|
const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
|
|
139
241
|
|
|
140
|
-
|
|
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;
|
|
242
|
+
const { attended, parentTty, envAttended, grantPresent } = resolveAttendance({ env, ttyProbe, platform, ppid });
|
|
153
243
|
|
|
154
244
|
let stamp;
|
|
155
245
|
if (attended) {
|
|
@@ -209,7 +299,7 @@ async function runSessionStart({ stdin, stdout, stderr, env, now }) {
|
|
|
209
299
|
decision: 'stamp',
|
|
210
300
|
stamp,
|
|
211
301
|
sessionId,
|
|
212
|
-
attendedSignals: {
|
|
302
|
+
attendedSignals: { parentTty, envAttended, grantPresent, platform },
|
|
213
303
|
});
|
|
214
304
|
return 0;
|
|
215
305
|
}
|
|
@@ -224,7 +314,7 @@ function formatStampLine(stamp) {
|
|
|
224
314
|
|
|
225
315
|
// --- pre-tool-use ----------------------------------------------------------
|
|
226
316
|
|
|
227
|
-
async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
317
|
+
async function runPreToolUse({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid }) {
|
|
228
318
|
const mode = env.PA_OPERATOR_ATTENDANCE_MODE === 'enforce' ? 'enforce' : 'observe';
|
|
229
319
|
let payload;
|
|
230
320
|
try {
|
|
@@ -240,7 +330,7 @@ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
|
240
330
|
return 1;
|
|
241
331
|
}
|
|
242
332
|
const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
|
|
243
|
-
const stamp = await readStamp({ env, sessionId }) || recomputeStamp(env);
|
|
333
|
+
const stamp = await readStamp({ env, sessionId }) || recomputeStamp({ env, ttyProbe, platform, ppid });
|
|
244
334
|
|
|
245
335
|
const bashCommand = toolName === 'Bash' && typeof toolInput.command === 'string'
|
|
246
336
|
? toolInput.command
|
|
@@ -250,7 +340,8 @@ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
|
250
340
|
// The flag-only forms (`claude --help`, `claude --version`, `claude -v`,
|
|
251
341
|
// `claude -h`) pass; everything else (`claude help foo`, `claude -p`,
|
|
252
342
|
// `claude mcp list`, bare `claude`) is a session launch and must go
|
|
253
|
-
// through the launcher.
|
|
343
|
+
// through the launcher. Leading `NAME=value` prefixes do not bypass
|
|
344
|
+
// the rule (0.1.58 fix B).
|
|
254
345
|
if (bashCommand) {
|
|
255
346
|
for (const line of bashCommand.split('\n')) {
|
|
256
347
|
if (RAW_CLAUDE_PATTERN.test(line)) {
|
|
@@ -299,7 +390,7 @@ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
|
299
390
|
return 0;
|
|
300
391
|
}
|
|
301
392
|
|
|
302
|
-
function recomputeStamp(env) {
|
|
393
|
+
function recomputeStamp({ env, ttyProbe, platform, ppid }) {
|
|
303
394
|
// Fallback when the session-start cache is missing (hook ordering, cold
|
|
304
395
|
// start). Mirror the session-start detection without the key material,
|
|
305
396
|
// which means a grant that LOOKS valid shape-wise is treated as
|
|
@@ -307,24 +398,24 @@ function recomputeStamp(env) {
|
|
|
307
398
|
// closed if session-start's richer stamp was lost, but it does not
|
|
308
399
|
// fabricate a verdict it does not have evidence for.
|
|
309
400
|
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
401
|
if (grantPresent) return { operatorPresent: false, grant: 'present', scope: 'dispatch-only' };
|
|
314
|
-
|
|
315
|
-
return { operatorPresent:
|
|
402
|
+
const { attended } = resolveAttendance({ env, ttyProbe, platform, ppid });
|
|
403
|
+
if (attended) return { operatorPresent: true, grant: 'none', scope: 'attended' };
|
|
404
|
+
return { operatorPresent: false, grant: 'none', scope: 'refusal', refusal: 'no stamp cache, env/parent-tty signals absent' };
|
|
316
405
|
}
|
|
317
406
|
|
|
318
407
|
async function denyOrObserve({ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand, stamp, rule, matched, message }) {
|
|
408
|
+
const safeBashCommand = bashCommand == null ? null : redactBashCommand(bashCommand);
|
|
409
|
+
const safeMatched = typeof matched === 'string' ? redactBashCommand(matched) : matched;
|
|
319
410
|
await appendDecisionLog({
|
|
320
411
|
env,
|
|
321
412
|
now: now(),
|
|
322
413
|
leg: 'pre-tool-use',
|
|
323
414
|
decision: mode === 'enforce' ? 'deny' : 'observe',
|
|
324
415
|
rule,
|
|
325
|
-
matched,
|
|
416
|
+
matched: safeMatched,
|
|
326
417
|
toolName,
|
|
327
|
-
bashCommand,
|
|
418
|
+
bashCommand: safeBashCommand,
|
|
328
419
|
stamp,
|
|
329
420
|
sessionId,
|
|
330
421
|
mode,
|
|
@@ -335,7 +426,9 @@ async function denyOrObserve({ env, now, mode, stdout, stderr, sessionId, toolNa
|
|
|
335
426
|
}
|
|
336
427
|
// Observe mode: write a one-line stderr note so the operator can see
|
|
337
428
|
// what would have been denied without the tool call being blocked.
|
|
338
|
-
|
|
429
|
+
// The matched text is redacted so a secret-bearing command does not
|
|
430
|
+
// leak into stderr either.
|
|
431
|
+
stderr.write(`pa-operator-attendance: OBSERVE would deny (${rule}: ${safeMatched})\n`);
|
|
339
432
|
return 0;
|
|
340
433
|
}
|
|
341
434
|
|
|
@@ -212,9 +212,9 @@ The stamp is produced by `pa-operator-attendance session-start` (a Claude Code S
|
|
|
212
212
|
|
|
213
213
|
A dispatch that genuinely needs its own child session goes through `pa-worker-launcher`, which mints the signed grant, strips the attended envelope, and spawns `claude --print` with `--setting-sources user` from a neutral cwd. Workers never invoke `claude` directly from Bash: the coordinator-hook deny plus the attendance pre-tool-use leg catch both.
|
|
214
214
|
|
|
215
|
-
Rollout is observe-mode by default for one point release: every would-deny lands as a JSONL line under `~/.local/share/pa/attendance-decisions.jsonl` but the tool call proceeds, so operators can inspect the gate's behaviour on real traffic before `PA_OPERATOR_ATTENDANCE_MODE=enforce` becomes the shipped default. Attendance detection is positive-proof: `
|
|
215
|
+
Rollout is observe-mode by default for one point release: every would-deny lands as a JSONL line under `~/.local/share/pa/attendance-decisions.jsonl` (with Bash commands redacted so argument-borne tokens, Authorization headers and URL credentials never land in the log) but the tool call proceeds, so operators can inspect the gate's behaviour on real traffic before `PA_OPERATOR_ATTENDANCE_MODE=enforce` becomes the shipped default. Attendance detection is positive-proof: `CLAUDE_CODE_SESSION_ATTENDED === '1' && parentHasControllingTty && !PA_WORKER_GRANT`. The hook's own stdout is always a pipe (the client reads the JSON envelope), so the discriminator is the hook's PARENT process: in an attended session the parent is the claude client attached to the operator's terminal; in a `claude -p` worker or any Bash-tool child the parent is a bash shell with no controlling tty. We probe `ps -o tty= -p <ppid>` on POSIX and treat a non-empty, non-`?`/non-`??` result as a real tty; on win32 we fall back to the env var alone and record `parentTty: 'unavailable'` on the stamp. Any other combination is unattended, which is the fail-safe direction. A client bump that strips the env var defaults the gate to "unattended" and surfaces loudly rather than silently failing open.
|
|
216
216
|
|
|
217
|
-
Reference: `packages/cli/src/operator-attendance/cli.mjs`, `packages/cli/src/worker-launcher/cli.mjs`, `packages/core/src/attendance/grant.mjs`. Rationale and
|
|
217
|
+
Reference: `packages/cli/src/operator-attendance/cli.mjs`, `packages/cli/src/worker-launcher/cli.mjs`, `packages/core/src/attendance/grant.mjs`. Rationale and incidents: `w-2026-10-02-dave-016` (review), `w-2026-10-02-dave-018` (build), `w-2026-10-02-dave-044` (0.1.58 detection + pattern + redaction fix, from Dex's 0.1.57 review).
|
|
218
218
|
|
|
219
219
|
---
|
|
220
220
|
|
package/package.json
CHANGED