mtech-ai-local-runner 0.1.19 → 0.1.21

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.
package/README.md CHANGED
@@ -10,6 +10,36 @@ The package stores safe runner configuration locally and keeps OAuth refresh cre
10
10
 
11
11
  Public npm installation requires no npm account or publishing OTP. Setup requires Gateway OAuth login, and local Claude Code must already be authenticated (`claude auth status`).
12
12
 
13
+ ## 0.1.21: exclude personal history from isolated job startup
14
+
15
+ The job-only Codex wrapper denies reads of the original account's `sessions`
16
+ and `archived_sessions`. A fresh private job database therefore cannot re-index
17
+ unrelated personal rollouts before MCP startup. Existing CLI/login paths,
18
+ personal settings, required authenticated MCP and unlimited admitted employee
19
+ work remain unchanged. Nothing is removed from the original history.
20
+
21
+ Pinned native fake-data comparison verified zero imported personal-history rows
22
+ and successful MCP initialization with this restriction. Real-account no-model
23
+ evidence remains partial because the private collector did not confirm its
24
+ owned exit/cleanup; real model completion is not established. Publication and
25
+ foreground installation do not update the daemon or authorize an execution.
26
+ See [local host comparison](https://bitbucket.org/mtechbal/api-cloud-service-ai-gateway/src/development/docs/ai-ops-gateway/codex-local-host-comparison.md).
27
+
28
+ ## 0.1.20: distinguish owned wrapper and target startup
29
+
30
+ Add bounded, read-only process-name/state/wait hints for the exact owned
31
+ wrapper PID. At most seven one-second queries run during preparation and stop
32
+ at admission, failure, cancellation or exit. Fixed categories exclude raw names,
33
+ paths, process IDs, arguments and environment. Cancellation hard-stops only its
34
+ owned query. Local logging receives a deep diagnostic copy so it cannot mutate
35
+ the evidence later reported to Gateway.
36
+
37
+ These are direct-child hints, not executable identity, native function or
38
+ historical cause proof; forked descendants are not inspected. Preparation
39
+ deadlines, authenticated MCP/tool gates and unlimited admitted employee work
40
+ remain unchanged. Installation does not update the daemon or run Codex.
41
+ See [owned launch observation](https://bitbucket.org/mtechbal/api-cloud-service-ai-gateway/src/development/docs/ai-ops-gateway/codex-owned-launch-observation.md).
42
+
13
43
  ## 0.1.19: bounded preparation phase timing
14
44
 
15
45
  Record fixed host milestones with monotonic elapsed milliseconds and remaining
@@ -1,7 +1,7 @@
1
1
  import { performance } from 'node:perf_hooks';
2
2
  import { PreparationClock, CODEX_PREPARATION_BUDGET_MS } from '../../safety/preparation-timing.js';
3
3
  import { localEndpointObservations } from '../../safety/local-tool-server.js';
4
- import { processStartupObservations } from '../../process/node-process-group-host.js';
4
+ import { processStartupObservations, freezeProcessLaunchObservation } from '../../process/node-process-group-host.js';
5
5
  import { executionError } from '../../safety/execution-failure.js';
6
6
  import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
7
7
  import { fingerprint as taskFingerprint } from '../../daemon/execution-coordinator.js';
@@ -70,9 +70,10 @@ export class CodexExecLaunchHost {
70
70
  let interruption;
71
71
  const mcpDetail = () => stage === 'codex_mcp' ? this.deps.execTools?.discoveryDetail() : undefined;
72
72
  const observations = () => { const invocation = requireOfficialExecPlan(this.plan); return [...(this.handle ? processStartupObservations(this.handle) : []), ...localEndpointObservations(this.plan.endpoint, invocation.env.AI_GATEWAY_JOB_MCP_TOKEN)]; };
73
+ const launchObservation = () => this.handle ? freezeProcessLaunchObservation(this.handle) : undefined;
73
74
  const timing = new PreparationClock();
74
75
  timing.mark('preparation_start');
75
- const interrupt = (code) => { timing.mark('preparation_failed'); interruption ??= executionError(fail(), stage, code, mcpDetail(), observations(), timing.snapshot()); };
76
+ const interrupt = (code) => { timing.mark('preparation_failed'); interruption ??= executionError(fail(), stage, code, mcpDetail(), observations(), timing.snapshot(), launchObservation()); };
76
77
  // Timer callbacks cannot interrupt synchronous native metadata checks. Keep
77
78
  // the same total budget and enforce it at effect boundaries with a monotonic
78
79
  // clock, rather than spawning/submitting after an already expired inspection.
@@ -174,7 +175,7 @@ export class CodexExecLaunchHost {
174
175
  requireExecHandshake(observed, h, this.plan);
175
176
  assertPreparationBudget();
176
177
  timing.mark('preparation_authorized');
177
- this.receiptValue = Object.freeze({ preparation_timing: timing.snapshot(), receipt_version: this.plan.mode === UNMANAGED_EXEC_MODE ? 'cli-exec-job-security-receipt-v3' : 'cli-exec-job-security-receipt-v2', evidence_origin: 'employee_runner', transport: EXEC_TRANSPORT, security_mode: this.plan.mode, ...(this.plan.mode === UNMANAGED_EXEC_MODE ? { policy_scope: this.inspection.snapshot.policyScope } : {}), phase: 'post_prompt_mcp_authorized', policy_evidence: 'reviewed_release_and_current_inputs', mcp_evidence: 'authenticated_server_discovery', native_policy_observed: false, first_inference_observed: false, execution_nonce: this.plan.executionNonce, invocation_fingerprint: this.plan.invocationFingerprint, ...this.plan.identity });
178
+ this.receiptValue = Object.freeze({ preparation_timing: timing.snapshot(), ...(launchObservation() ? { launch_process: launchObservation() } : {}), receipt_version: this.plan.mode === UNMANAGED_EXEC_MODE ? 'cli-exec-job-security-receipt-v3' : 'cli-exec-job-security-receipt-v2', evidence_origin: 'employee_runner', transport: EXEC_TRANSPORT, security_mode: this.plan.mode, ...(this.plan.mode === UNMANAGED_EXEC_MODE ? { policy_scope: this.inspection.snapshot.policyScope } : {}), phase: 'post_prompt_mcp_authorized', policy_evidence: 'reviewed_release_and_current_inputs', mcp_evidence: 'authenticated_server_discovery', native_policy_observed: false, first_inference_observed: false, execution_nonce: this.plan.executionNonce, invocation_fingerprint: this.plan.invocationFingerprint, ...this.plan.identity });
178
179
  return h;
179
180
  }
180
181
  timing.mark('mcp_wait_started');
@@ -204,14 +205,14 @@ export class CodexExecLaunchHost {
204
205
  requireExecHandshake(handshake, h, this.plan);
205
206
  assertPreparationBudget();
206
207
  timing.mark('preparation_authorized');
207
- this.receiptValue = Object.freeze({ ...receipt, preparation_timing: timing.snapshot() });
208
+ this.receiptValue = Object.freeze({ ...receipt, preparation_timing: timing.snapshot(), ...(launchObservation() ? { launch_process: launchObservation() } : {}) });
208
209
  return h;
209
210
  })();
210
211
  return await Promise.race([preparation, stopped]);
211
212
  }
212
213
  catch (error) {
213
214
  timing.mark('preparation_failed');
214
- const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail(), observations(), timing.snapshot());
215
+ const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail(), observations(), timing.snapshot(), launchObservation());
215
216
  await this.cancel().catch(() => { });
216
217
  throw safe;
217
218
  }
@@ -14,12 +14,22 @@ export function accountAncestorPaths(account) {
14
14
  }
15
15
  return Object.freeze(parents);
16
16
  }
17
+ /** New job processes must not re-index unrelated interactive history into
18
+ * their fresh private SQLite DB. Login stays at the original account root. */
19
+ export function personalHistoryReadDenial(account) {
20
+ accountAncestorPaths(account);
21
+ return '(deny file-read* ' + ['sessions', 'archived_sessions'].map(name => {
22
+ const root = JSON.stringify(path.join(account, name));
23
+ return `(literal ${root}) (subpath ${root})`;
24
+ }).join(' ') + ')';
25
+ }
17
26
  /** Additional per-process denial, not a grant of authority or global policy edit.
18
27
  * file-read* is intentional: data-only denial permits a final symlink alias.
19
28
  * Ancestor symlink denials prevent a later account-directory redirect. */
20
29
  export function globalInstructionProfile(account) {
21
30
  const ancestors = accountAncestorPaths(account), literal = (p) => `(literal ${JSON.stringify(p)})`;
22
31
  const profile = '(version 1)(allow default)'
32
+ + personalHistoryReadDenial(account)
23
33
  + '(deny file-read* ' + ['AGENTS.md', 'AGENTS.override.md'].map(name => literal(path.join(account, name))).join(' ') + ')'
24
34
  + '(deny file-read* (require-all (vnode-type SYMLINK) (require-any ' + ancestors.map(literal).join(' ') + ')))';
25
35
  if (profile.length > 64000)
@@ -256,7 +256,7 @@ export class ExecutionCoordinator {
256
256
  failureDiagnostic = diagnostic;
257
257
  // Log before reporting so an unavailable Gateway does not erase the cause.
258
258
  try {
259
- this.deps.reportExecutionFailure?.({ ...diagnostic });
259
+ this.deps.reportExecutionFailure?.(structuredClone(diagnostic));
260
260
  }
261
261
  catch { /* logging must not change terminal reconciliation */ }
262
262
  terminal = { eventType: "failed", summary: "daemon execution or event reporting failed", payload: { execution_failure: { ...diagnostic } } };
@@ -1,4 +1,8 @@
1
+ import path from 'node:path';
2
+ import { OwnedLaunchObserver } from './owned-launch-observer.js';
1
3
  const startup = new WeakMap();
4
+ const launchObservers = new WeakMap();
5
+ export function freezeProcessLaunchObservation(handle) { return launchObservers.get(handle)?.freeze(); }
2
6
  /** Fixed lifecycle/text observations from this owned host only. Never raw stderr or authority. */
3
7
  export function processStartupObservations(handle) { return [...(startup.get(handle) ?? [])]; }
4
8
  import { spawn } from "node:child_process";
@@ -9,7 +13,22 @@ export class NodeProcessGroupHost {
9
13
  throw new Error("Codex process isolation requires macOS");
10
14
  const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
11
15
  const observations = new Set(['host_observer_v2']);
12
- child.once('spawn', () => observations.add('host_process_spawned'));
16
+ let launch;
17
+ child.once('spawn', () => {
18
+ observations.add('host_process_spawned');
19
+ if (command === '/usr/bin/sandbox-exec' && args[0] === '-p' && path.isAbsolute(args[2] ?? '')) {
20
+ try {
21
+ launch = new OwnedLaunchObserver({ pid: child.pid, parentPid: process.pid, wrapperName: path.basename(command), targetName: path.basename(args[2]), alive: () => owned && child.exitCode === null && child.signalCode === null, onSample: sample => {
22
+ observations.add(sample.status === 'observed' ? sample.image === 'wrapper_name_match' ? 'host_os_wrapper_name_seen' : sample.image === 'target_name_match' ? 'host_os_target_name_seen' : 'host_os_other_name_seen' : 'host_os_query_unavailable');
23
+ } });
24
+ launchObservers.set(handle, launch);
25
+ launch.start();
26
+ }
27
+ catch {
28
+ observations.add('host_os_query_unavailable');
29
+ }
30
+ }
31
+ });
13
32
  child.stdin.once('finish', () => observations.add('host_stdin_finished'));
14
33
  let failure;
15
34
  let owned = true;
@@ -27,6 +46,7 @@ export class NodeProcessGroupHost {
27
46
  callback(code, signal);
28
47
  };
29
48
  const kill = (signal = "SIGKILL") => {
49
+ launch?.stop();
30
50
  if (!owned || !child.pid)
31
51
  return;
32
52
  try {
@@ -0,0 +1,132 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { performance } from 'node:perf_hooks';
4
+ const issued = new WeakMap();
5
+ export function trustedLaunchObservation(token) {
6
+ const value = token && typeof token === 'object' ? issued.get(token) : undefined;
7
+ return value ? structuredClone(value) : undefined;
8
+ }
9
+ /** Only one exact, directly-owned PID. No argv/environment/descendant scan,
10
+ * stack attachment, credentials, network, or privileged query. */
11
+ async function queryOwnedPid(pid, signal) {
12
+ return new Promise(resolve => {
13
+ let output = '', settled = false;
14
+ let timer;
15
+ let onAbort;
16
+ const finish = (value) => { if (settled)
17
+ return; settled = true; if (timer)
18
+ clearTimeout(timer); if (onAbort)
19
+ signal.removeEventListener('abort', onAbort); resolve(value); };
20
+ try {
21
+ const child = spawn('/bin/ps', ['-p', String(pid), '-o', 'pid=,ppid=,stat=,wchan=,comm='], { shell: false, env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] });
22
+ child.stdout.on('data', (chunk) => { if (settled)
23
+ return; if (Buffer.byteLength(output) + chunk.length > 8192) {
24
+ finish({ status: 'output_invalid' });
25
+ try {
26
+ child.kill('SIGKILL');
27
+ }
28
+ catch { /* owned query already gone */ }
29
+ return;
30
+ } output += chunk.toString('utf8'); });
31
+ child.on('error', () => finish({ status: 'query_failed' }));
32
+ child.once('close', code => finish(code === 0 ? { status: 'ok', output } : { status: 'query_failed' }));
33
+ onAbort = () => { if (settled)
34
+ return; try {
35
+ child.kill('SIGKILL');
36
+ }
37
+ catch { /* owned query already gone */ } finish({ status: 'query_failed' }); };
38
+ signal.addEventListener('abort', onAbort, { once: true });
39
+ if (signal.aborted) {
40
+ onAbort();
41
+ return;
42
+ }
43
+ timer = setTimeout(() => { finish({ status: 'query_timeout' }); try {
44
+ child.kill('SIGKILL');
45
+ }
46
+ catch { /* owned query already gone */ } }, 1000);
47
+ timer.unref();
48
+ }
49
+ catch {
50
+ finish({ status: 'query_failed' });
51
+ }
52
+ });
53
+ }
54
+ /** Names/wait symbols are observational hints, never executable identity or a
55
+ * native function attestation. Arbitrary process strings never leave this file. */
56
+ export class OwnedLaunchObserver {
57
+ input;
58
+ origin = performance.now();
59
+ active = false;
60
+ used = false;
61
+ pending = false;
62
+ discarded = false;
63
+ abort = new AbortController();
64
+ timer;
65
+ values = [];
66
+ frozen;
67
+ constructor(input) {
68
+ this.input = input;
69
+ if (!Number.isSafeInteger(input.pid) || input.pid < 1 || !Number.isSafeInteger(input.parentPid) || input.parentPid < 1)
70
+ throw Error('OWNED_LAUNCH_OBSERVER_REJECTED');
71
+ }
72
+ start() { if (this.used || this.frozen)
73
+ return; this.used = true; this.active = true; void this.sample(0); }
74
+ async sample(index) {
75
+ if (!this.active || !this.input.alive())
76
+ return;
77
+ this.pending = true;
78
+ let result;
79
+ try {
80
+ result = await (this.input.query ?? queryOwnedPid)(this.input.pid, this.abort.signal);
81
+ }
82
+ catch {
83
+ result = { status: 'query_failed' };
84
+ }
85
+ this.pending = false;
86
+ if (!this.active || !this.input.alive())
87
+ return;
88
+ const previous = this.values.at(-1)?.elapsed_ms ?? 0, value = performance.now() - this.origin;
89
+ const elapsed_ms = Math.max(previous, Number.isFinite(value) ? Math.min(2147483647, Math.max(0, Math.floor(value))) : previous);
90
+ const sample = { elapsed_ms, status: result.status === 'ok' ? 'output_invalid' : result.status };
91
+ if (result.status === 'ok' && typeof result.output === 'string' && result.output.length <= 8192) {
92
+ const line = result.output.trim();
93
+ const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+([^\r\n\0]+)$/.exec(line);
94
+ if (match) {
95
+ if (Number(match[1]) !== this.input.pid || Number(match[2]) !== this.input.parentPid)
96
+ sample.status = 'owner_mismatch';
97
+ else {
98
+ sample.status = 'observed';
99
+ const name = path.basename(match[5].trim());
100
+ sample.image = name === this.input.wrapperName ? 'wrapper_name_match' : name === this.input.targetName ? 'target_name_match' : 'other';
101
+ const states = { R: 'running', S: 'sleeping', I: 'sleeping', U: 'uninterruptible', D: 'uninterruptible', T: 'stopped', Z: 'zombie' };
102
+ sample.state = states[match[3][0]] ?? 'other';
103
+ const wait = match[4].toLowerCase();
104
+ sample.wait = ['-', '0', '0x0', '??'].includes(wait) ? 'not_reported' : /^(?:__)?psynch(?:_|$)/.test(wait) ? 'psynch' : /^mach_msg(?:_|$)/.test(wait) ? 'mach_message' : /^kevent/.test(wait) ? 'kevent' : /^(?:pipe_?read|piperd)$/.test(wait) ? 'pipe_read' : /^(?:wait4|wait)$/.test(wait) ? 'child_wait' : 'other';
105
+ }
106
+ }
107
+ }
108
+ this.values.push(sample);
109
+ try {
110
+ this.input.onSample?.(structuredClone(sample));
111
+ }
112
+ catch { /* telemetry cannot alter execution */ }
113
+ const schedule = [0, 100, 500, 1500, 5000, 15000, 25000];
114
+ const next = schedule[index + 1];
115
+ if (next !== undefined && this.active) {
116
+ this.timer = setTimeout(() => { void this.sample(index + 1); }, Math.max(0, next - (performance.now() - this.origin)));
117
+ this.timer.unref();
118
+ }
119
+ }
120
+ stop() { this.used = true; if (!this.active)
121
+ return; this.active = false; this.discarded ||= this.pending; if (this.timer)
122
+ clearTimeout(this.timer); this.abort.abort(); }
123
+ freeze() {
124
+ if (!this.frozen) {
125
+ this.stop();
126
+ this.frozen = { version: 'owned-launch-process-v1', collector: 'owned_pid_ps', samples: structuredClone(this.values), pending_query_discarded: this.discarded };
127
+ }
128
+ const token = structuredClone(this.frozen);
129
+ issued.set(token, structuredClone(this.frozen));
130
+ return token;
131
+ }
132
+ }
@@ -1,7 +1,9 @@
1
+ import { trustedLaunchObservation } from '../process/owned-launch-observer.js';
1
2
  import { trustedPreparationTiming } from './preparation-timing.js';
2
3
  import { GatewayToolError } from '../gateway-client.js';
3
4
  /** Observations only: provider text cannot authorize a tool or certify a cause. */
4
5
  export const STARTUP_OBSERVATIONS = Object.freeze([
6
+ 'host_os_wrapper_name_seen', 'host_os_target_name_seen', 'host_os_other_name_seen', 'host_os_query_unavailable',
5
7
  'host_observer_v2', 'host_process_spawned', 'host_process_spawn_failed', 'host_process_exited',
6
8
  'host_stdin_write_requested', 'host_stdin_end_requested', 'host_stdin_finished', 'host_stdin_failed',
7
9
  'host_stdout_data', 'host_stdout_line', 'host_stdout_ended', 'host_stderr_data', 'host_stderr_ended',
@@ -36,7 +38,7 @@ export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
36
38
  }
37
39
  return { stage, code: fallback };
38
40
  }
39
- export function executionError(error, stage, fallback, detail, observations, preparationTiming) {
41
+ export function executionError(error, stage, fallback, detail, observations, preparationTiming, launchObservation) {
40
42
  const diagnostic = executionFailure(error, stage, fallback);
41
43
  // Only trusted callers supply this fixed vocabulary. Serialized provider
42
44
  // errors, messages and stderr never become diagnostic authority.
@@ -52,6 +54,11 @@ export function executionError(error, stage, fallback, detail, observations, pre
52
54
  if (timing)
53
55
  diagnostic.preparation_timing = timing;
54
56
  }
57
+ if (!diagnostic.launch_process) {
58
+ const observed = trustedLaunchObservation(launchObservation);
59
+ if (observed)
60
+ diagnostic.launch_process = observed;
61
+ }
55
62
  const safe = new Error(fallback);
56
63
  issued.set(safe, Object.freeze(diagnostic));
57
64
  return safe;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {