@stravica-ai/digital-operator-pa 0.1.57 → 0.1.59
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,128 @@ 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 (any scheme: Bearer, token, Basic,
|
|
185
|
+
// Digest, or an unknown word; and the raw-value no-scheme form). Three
|
|
186
|
+
// passes handle the single-quoted, double-quoted, and unquoted header
|
|
187
|
+
// forms. The quoted passes consume up to the closing quote so secrets
|
|
188
|
+
// that contain whitespace or inner punctuation (token schemes, Basic
|
|
189
|
+
// base64, Digest comma-separated field lists with inner "quoted"
|
|
190
|
+
// fields) never leak past the scheme word. 0.1.58 only redacted the
|
|
191
|
+
// scheme word for non-Bearer schemes, so `Authorization: token <x>`
|
|
192
|
+
// landed in the log as `Authorization: <redacted> <x>` (Dex's 0.1.58
|
|
193
|
+
// verification). 0.1.59 extends the redaction to the full header
|
|
194
|
+
// value.
|
|
195
|
+
out = out.replace(/'([^'\n]*?Authorization\s*:\s*)[^'\n]+(?=')/gi, (_m, pre) => `'${pre}<redacted>`);
|
|
196
|
+
out = out.replace(/"([^"\n]*?Authorization\s*:\s*)[^"\n]+(?=")/gi, (_m, pre) => `"${pre}<redacted>`);
|
|
197
|
+
out = out.replace(/(Authorization\s*:\s*)[^\s'"`,;)]+(?:\s+[^\s'"`,;)]+)?/gi, '$1<redacted>');
|
|
198
|
+
// --token / --token= and token=
|
|
199
|
+
out = out.replace(/(--?token[\s=]+)([^\s'"`;)]+)/gi, '$1<redacted>');
|
|
200
|
+
// NAME=value (env-style). Match a word boundary that is not preceded
|
|
201
|
+
// by `-` or `=` so `--foo=bar` and `=key=val` do not re-match the
|
|
202
|
+
// trailing key.
|
|
203
|
+
out = out.replace(/(^|[\s;&|`(])((?:env\s+)?)([A-Za-z_][A-Za-z0-9_]*)=([^\s'"`]+)/g,
|
|
204
|
+
(_m, lead, envWord, key, _val) => `${lead}${envWord}${key}=<redacted>`);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
97
208
|
export async function main(argv = [], {
|
|
98
209
|
stdin = process.stdin,
|
|
99
210
|
stdout = process.stdout,
|
|
100
211
|
stderr = process.stderr,
|
|
101
212
|
env = process.env,
|
|
102
213
|
now = () => new Date(),
|
|
214
|
+
ttyProbe = defaultTtyProbe,
|
|
215
|
+
platform = process.platform,
|
|
216
|
+
ppid = process.ppid,
|
|
103
217
|
} = {}) {
|
|
104
218
|
if (argv.includes('-h') || argv.includes('--help')) {
|
|
105
219
|
stderr.write(USAGE);
|
|
@@ -112,10 +226,10 @@ export async function main(argv = [], {
|
|
|
112
226
|
}
|
|
113
227
|
try {
|
|
114
228
|
if (sub === 'session-start') {
|
|
115
|
-
return await runSessionStart({ stdin, stdout, stderr, env, now });
|
|
229
|
+
return await runSessionStart({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid });
|
|
116
230
|
}
|
|
117
231
|
if (sub === 'pre-tool-use') {
|
|
118
|
-
return await runPreToolUse({ stdin, stdout, stderr, env, now });
|
|
232
|
+
return await runPreToolUse({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid });
|
|
119
233
|
}
|
|
120
234
|
stderr.write(`pa-operator-attendance: unknown subcommand "${sub}"\n`);
|
|
121
235
|
return 1;
|
|
@@ -131,25 +245,13 @@ export async function main(argv = [], {
|
|
|
131
245
|
|
|
132
246
|
// --- session-start ---------------------------------------------------------
|
|
133
247
|
|
|
134
|
-
async function runSessionStart({ stdin, stdout, stderr, env, now }) {
|
|
248
|
+
async function runSessionStart({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid }) {
|
|
135
249
|
const payload = await readPayload(stdin);
|
|
136
250
|
// SessionStart has no required fields we gate on; we still parse it so
|
|
137
251
|
// a payload with `session_id` can key the cache properly.
|
|
138
252
|
const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
|
|
139
253
|
|
|
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;
|
|
254
|
+
const { attended, parentTty, envAttended, grantPresent } = resolveAttendance({ env, ttyProbe, platform, ppid });
|
|
153
255
|
|
|
154
256
|
let stamp;
|
|
155
257
|
if (attended) {
|
|
@@ -209,7 +311,7 @@ async function runSessionStart({ stdin, stdout, stderr, env, now }) {
|
|
|
209
311
|
decision: 'stamp',
|
|
210
312
|
stamp,
|
|
211
313
|
sessionId,
|
|
212
|
-
attendedSignals: {
|
|
314
|
+
attendedSignals: { parentTty, envAttended, grantPresent, platform },
|
|
213
315
|
});
|
|
214
316
|
return 0;
|
|
215
317
|
}
|
|
@@ -224,7 +326,7 @@ function formatStampLine(stamp) {
|
|
|
224
326
|
|
|
225
327
|
// --- pre-tool-use ----------------------------------------------------------
|
|
226
328
|
|
|
227
|
-
async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
329
|
+
async function runPreToolUse({ stdin, stdout, stderr, env, now, ttyProbe, platform, ppid }) {
|
|
228
330
|
const mode = env.PA_OPERATOR_ATTENDANCE_MODE === 'enforce' ? 'enforce' : 'observe';
|
|
229
331
|
let payload;
|
|
230
332
|
try {
|
|
@@ -240,7 +342,7 @@ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
|
240
342
|
return 1;
|
|
241
343
|
}
|
|
242
344
|
const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
|
|
243
|
-
const stamp = await readStamp({ env, sessionId }) || recomputeStamp(env);
|
|
345
|
+
const stamp = await readStamp({ env, sessionId }) || recomputeStamp({ env, ttyProbe, platform, ppid });
|
|
244
346
|
|
|
245
347
|
const bashCommand = toolName === 'Bash' && typeof toolInput.command === 'string'
|
|
246
348
|
? toolInput.command
|
|
@@ -250,7 +352,8 @@ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
|
250
352
|
// The flag-only forms (`claude --help`, `claude --version`, `claude -v`,
|
|
251
353
|
// `claude -h`) pass; everything else (`claude help foo`, `claude -p`,
|
|
252
354
|
// `claude mcp list`, bare `claude`) is a session launch and must go
|
|
253
|
-
// through the launcher.
|
|
355
|
+
// through the launcher. Leading `NAME=value` prefixes do not bypass
|
|
356
|
+
// the rule (0.1.58 fix B).
|
|
254
357
|
if (bashCommand) {
|
|
255
358
|
for (const line of bashCommand.split('\n')) {
|
|
256
359
|
if (RAW_CLAUDE_PATTERN.test(line)) {
|
|
@@ -299,7 +402,7 @@ async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
|
299
402
|
return 0;
|
|
300
403
|
}
|
|
301
404
|
|
|
302
|
-
function recomputeStamp(env) {
|
|
405
|
+
function recomputeStamp({ env, ttyProbe, platform, ppid }) {
|
|
303
406
|
// Fallback when the session-start cache is missing (hook ordering, cold
|
|
304
407
|
// start). Mirror the session-start detection without the key material,
|
|
305
408
|
// which means a grant that LOOKS valid shape-wise is treated as
|
|
@@ -307,24 +410,24 @@ function recomputeStamp(env) {
|
|
|
307
410
|
// closed if session-start's richer stamp was lost, but it does not
|
|
308
411
|
// fabricate a verdict it does not have evidence for.
|
|
309
412
|
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
413
|
if (grantPresent) return { operatorPresent: false, grant: 'present', scope: 'dispatch-only' };
|
|
314
|
-
|
|
315
|
-
return { operatorPresent:
|
|
414
|
+
const { attended } = resolveAttendance({ env, ttyProbe, platform, ppid });
|
|
415
|
+
if (attended) return { operatorPresent: true, grant: 'none', scope: 'attended' };
|
|
416
|
+
return { operatorPresent: false, grant: 'none', scope: 'refusal', refusal: 'no stamp cache, env/parent-tty signals absent' };
|
|
316
417
|
}
|
|
317
418
|
|
|
318
419
|
async function denyOrObserve({ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand, stamp, rule, matched, message }) {
|
|
420
|
+
const safeBashCommand = bashCommand == null ? null : redactBashCommand(bashCommand);
|
|
421
|
+
const safeMatched = typeof matched === 'string' ? redactBashCommand(matched) : matched;
|
|
319
422
|
await appendDecisionLog({
|
|
320
423
|
env,
|
|
321
424
|
now: now(),
|
|
322
425
|
leg: 'pre-tool-use',
|
|
323
426
|
decision: mode === 'enforce' ? 'deny' : 'observe',
|
|
324
427
|
rule,
|
|
325
|
-
matched,
|
|
428
|
+
matched: safeMatched,
|
|
326
429
|
toolName,
|
|
327
|
-
bashCommand,
|
|
430
|
+
bashCommand: safeBashCommand,
|
|
328
431
|
stamp,
|
|
329
432
|
sessionId,
|
|
330
433
|
mode,
|
|
@@ -335,7 +438,9 @@ async function denyOrObserve({ env, now, mode, stdout, stderr, sessionId, toolNa
|
|
|
335
438
|
}
|
|
336
439
|
// Observe mode: write a one-line stderr note so the operator can see
|
|
337
440
|
// what would have been denied without the tool call being blocked.
|
|
338
|
-
|
|
441
|
+
// The matched text is redacted so a secret-bearing command does not
|
|
442
|
+
// leak into stderr either.
|
|
443
|
+
stderr.write(`pa-operator-attendance: OBSERVE would deny (${rule}: ${safeMatched})\n`);
|
|
339
444
|
return 0;
|
|
340
445
|
}
|
|
341
446
|
|
|
@@ -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