@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.1
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/LICENSE +201 -0
- package/NOTICE +1 -0
- package/README.md +124 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/ZCode-LICENSE +202 -0
- package/dist/index.mjs +4871 -0
- package/dist/native-command-plan.LICENSE.txt +221 -0
- package/dist/native-command-plan.json +16 -0
- package/dist/native-command-plan.mjs +8 -0
- package/dist/rpc-entry.mjs +5682 -0
- package/package.json +169 -4
- package/src/native/a2a-receipt-source.mjs +19 -0
- package/src/native/a2a-tools.mjs +517 -0
- package/src/native/activity-status.mjs +30 -0
- package/src/native/artifact-codec.mjs +481 -0
- package/src/native/artifact-store.mjs +1162 -0
- package/src/native/assistant-stream.mjs +76 -0
- package/src/native/background-command-port.mjs +95 -0
- package/src/native/browser-action-observation.mjs +43 -0
- package/src/native/browser-availability.mjs +22 -0
- package/src/native/browser-canvas-metrics.mjs +71 -0
- package/src/native/browser-dom.mjs +193 -0
- package/src/native/browser-playwright.mjs +1249 -0
- package/src/native/browser-vision.mjs +369 -0
- package/src/native/browser-workspace-policy.mjs +22 -0
- package/src/native/browser-workspace-protocol.mjs +95 -0
- package/src/native/browser-workspace-server.mjs +164 -0
- package/src/native/browser-workspace.mjs +355 -0
- package/src/native/captured-source.mjs +3 -0
- package/src/native/child-budget-allocation.mjs +87 -0
- package/src/native/child-capabilities.mjs +35 -0
- package/src/native/child-command-resource.mjs +20 -0
- package/src/native/child-history.mjs +188 -0
- package/src/native/child-merge.mjs +98 -0
- package/src/native/child-model-surface.mjs +19 -0
- package/src/native/child-observation.mjs +100 -0
- package/src/native/child-owner.mjs +620 -0
- package/src/native/child-publication.mjs +76 -0
- package/src/native/child-readers.mjs +366 -0
- package/src/native/child-tools.mjs +46 -0
- package/src/native/child-workspace-binding.mjs +29 -0
- package/src/native/child-workspace-tools.mjs +1 -0
- package/src/native/child-workspaces.mjs +655 -0
- package/src/native/children.mjs +365 -0
- package/src/native/command-plan-worker.mjs +14 -0
- package/src/native/command-plan.mjs +31 -0
- package/src/native/command-progress.mjs +64 -0
- package/src/native/command-shell.mjs +9 -0
- package/src/native/command-workspace-observation.mjs +36 -0
- package/src/native/component-tools.mjs +280 -0
- package/src/native/context-counter.mjs +133 -0
- package/src/native/context-markers.mjs +51 -0
- package/src/native/context-profile.mjs +72 -0
- package/src/native/context-status.mjs +27 -0
- package/src/native/conversation-command-control.mjs +133 -0
- package/src/native/delivery-timeline.mjs +39 -0
- package/src/native/dsh-background-ownership.mjs +102 -0
- package/src/native/dsh-managed-ownership.mjs +23 -0
- package/src/native/dsh-turn-contract.mjs +3 -0
- package/src/native/dsh-turn-ownership.mjs +54 -0
- package/src/native/dsh-turn-runtime.mjs +498 -0
- package/src/native/e2b-background-supervisor.mjs +347 -0
- package/src/native/e2b-background.mjs +368 -0
- package/src/native/e2b-command-state.mjs +40 -0
- package/src/native/e2b-command-tools.mjs +486 -0
- package/src/native/e2b-sandbox-owner.mjs +227 -0
- package/src/native/hook-executor.mjs +249 -0
- package/src/native/instruction-assembly.mjs +60 -0
- package/src/native/instruction-context.mjs +54 -0
- package/src/native/local-background-supervisor.mjs +108 -0
- package/src/native/local-background.mjs +207 -0
- package/src/native/local-command-evidence.mjs +57 -0
- package/src/native/local-command-policy-worker.mjs +197 -0
- package/src/native/local-command-policy.mjs +46 -0
- package/src/native/local-command-state.mjs +177 -0
- package/src/native/local-command-tools.mjs +302 -0
- package/src/native/local-execution-profile.mjs +36 -0
- package/src/native/local-output-policy.mjs +92 -0
- package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
- package/src/native/local-process-bin/darwin-x64/launcher +0 -0
- package/src/native/local-process-bin/linux-arm64/launcher +0 -0
- package/src/native/local-process-bin/linux-x64/launcher +0 -0
- package/src/native/local-process-launcher.c +183 -0
- package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
- package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
- package/src/native/local-process-licenses/NOTICE +21 -0
- package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
- package/src/native/local-process-manifest.json +32 -0
- package/src/native/local-process.mjs +246 -0
- package/src/native/local-resource-profile.mjs +32 -0
- package/src/native/memory-candidate.mjs +121 -0
- package/src/native/memory-extraction-run.mjs +60 -0
- package/src/native/memory-extraction-tools.mjs +116 -0
- package/src/native/memory-recall.mjs +65 -0
- package/src/native/memory-store.mjs +334 -0
- package/src/native/memory-tools.mjs +329 -0
- package/src/native/model-anthropic-cache.mjs +49 -0
- package/src/native/model-anthropic-tool-search.mjs +85 -0
- package/src/native/model-anthropic.mjs +753 -0
- package/src/native/model-chat-usage.mjs +24 -0
- package/src/native/model-inline-reasoning.mjs +111 -0
- package/src/native/model-media.mjs +174 -0
- package/src/native/model-normalize.mjs +228 -0
- package/src/native/model-openai-chat.mjs +561 -0
- package/src/native/model-port.mjs +361 -0
- package/src/native/model-provider-compat.mjs +129 -0
- package/src/native/model-replay.mjs +153 -0
- package/src/native/model-request.mjs +15 -0
- package/src/native/model-responses.mjs +1205 -0
- package/src/native/model-sse.mjs +171 -0
- package/src/native/model-surface-host.mjs +27 -0
- package/src/native/model-tool-search-policy.mjs +16 -0
- package/src/native/model-transport-observation.mjs +27 -0
- package/src/native/owned-child.mjs +114 -0
- package/src/native/owned-command.mjs +106 -0
- package/src/native/python-syntax-worker.mjs +60 -0
- package/src/native/python-syntax.mjs +50 -0
- package/src/native/read-interruption.mjs +16 -0
- package/src/native/result-stream.mjs +63 -0
- package/src/native/ruff-ast.mjs +95 -0
- package/src/native/run-observation.mjs +128 -0
- package/src/native/session-delivery-journal.mjs +96 -0
- package/src/native/session-delivery.mjs +434 -0
- package/src/native/shell-analysis.mjs +50 -0
- package/src/native/skill-disclosure.mjs +404 -0
- package/src/native/skill-events.mjs +164 -0
- package/src/native/skill-tools.mjs +813 -0
- package/src/native/source-page.mjs +73 -0
- package/src/native/sqlite-session-inbox.mjs +42 -0
- package/src/native/sqlite-store.mjs +617 -0
- package/src/native/tool-authority.mjs +99 -0
- package/src/native/tool-guidance.mjs +12 -0
- package/src/native/tool-observation.mjs +75 -0
- package/src/native/tool-states.mjs +14 -0
- package/src/native/verification-authority.mjs +37 -0
- package/src/native/web-tools.mjs +643 -0
- package/src/native/workspace-checkpoints.mjs +312 -0
- package/src/native/workspace-context.mjs +61 -0
- package/src/native/workspace-display.mjs +39 -0
- package/src/native/workspace-edit-match.mjs +19 -0
- package/src/native/workspace-edit-worker.mjs +15 -0
- package/src/native/workspace-editing.mjs +447 -0
- package/src/native/workspace-image.mjs +72 -0
- package/src/native/workspace-instructions.mjs +107 -0
- package/src/native/workspace-mutation-lock.mjs +74 -0
- package/src/native/workspace-patch-worker.mjs +17 -0
- package/src/native/workspace-patch.mjs +17 -0
- package/src/native/workspace-paths.mjs +225 -0
- package/src/native/workspace-state.mjs +272 -0
- package/src/native/workspace-subject-observer.mjs +84 -0
- package/src/native/workspace-tools.mjs +1740 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Separate trusted Node process: the tool/Host caller may disappear while this
|
|
2
|
+
// supervisor continues. Its own death closes the bounded runner's owner pipe.
|
|
3
|
+
import { constants, closeSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { commandRequest } from './local-background.mjs';
|
|
6
|
+
import { runBoundedProcess } from './local-process.mjs';
|
|
7
|
+
import { commandFailureEvidence, commandExceptionCause } from './local-command-evidence.mjs';
|
|
8
|
+
import { shellCommandArgv } from './command-shell.mjs';
|
|
9
|
+
import { START_GRACE_MS, TERMINAL, atomicPrivate, check, digest, fsyncPrivate, lockUntil, openPrivate,
|
|
10
|
+
privateDirectory, privateFile, readRecord, unknownRecord, validId, writeRecord } from './local-command-state.mjs';
|
|
11
|
+
|
|
12
|
+
async function inputRequest() {
|
|
13
|
+
let size = 0; const parts = [];
|
|
14
|
+
const timer = setTimeout(() => process.stdin.destroy(Error('request_timeout')), START_GRACE_MS);
|
|
15
|
+
try {
|
|
16
|
+
for await (const part of process.stdin) {
|
|
17
|
+
size += part.length; check(size <= 1024 * 1024, 'background_command_request_too_large'); parts.push(part);
|
|
18
|
+
}
|
|
19
|
+
return commandRequest(JSON.parse(Buffer.concat(parts).toString('utf8')));
|
|
20
|
+
} finally { clearTimeout(timer); }
|
|
21
|
+
}
|
|
22
|
+
async function supervise() {
|
|
23
|
+
const [directory, owner, commandId] = process.argv.slice(2);
|
|
24
|
+
check(typeof directory === 'string' && validId(owner) && validId(commandId), 'invalid_background_supervisor_arguments');
|
|
25
|
+
privateDirectory(directory);
|
|
26
|
+
const unlock = await lockUntil(join(directory, 'supervisor.sqlite'), performance.now() + START_GRACE_MS);
|
|
27
|
+
let record, stage = 'request', commandEvidence;
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const cancel = () => controller.abort();
|
|
30
|
+
process.on('SIGTERM', cancel); process.on('SIGINT', cancel);
|
|
31
|
+
let poll;
|
|
32
|
+
try {
|
|
33
|
+
record = readRecord(directory, owner, commandId);
|
|
34
|
+
// A duplicate/stale supervisor can never reclaim a prepared operation.
|
|
35
|
+
if (record.status !== 'starting') return;
|
|
36
|
+
const request = await inputRequest();
|
|
37
|
+
check(record.requestDigest === digest(request), 'background_command_request_digest_mismatch');
|
|
38
|
+
record = readRecord(directory, owner, commandId);
|
|
39
|
+
if (record.status !== 'starting') return;
|
|
40
|
+
if (Date.now() - record.createdAt >= START_GRACE_MS) {
|
|
41
|
+
unknownRecord(directory, record, 'supervisor_start_expired'); return;
|
|
42
|
+
}
|
|
43
|
+
const persistedStream = !!request.outputPolicy;
|
|
44
|
+
const persist = (status, exitCode, stdout, stderr, metadata = {}) => {
|
|
45
|
+
// Complete bytes are fsynced and published BEFORE the terminal receipt.
|
|
46
|
+
if (persistedStream) {
|
|
47
|
+
// persisted-stream/v1: runBoundedProcess streamed the FULL capped output
|
|
48
|
+
// to the owned files via onOutput. fsync those files in place before the
|
|
49
|
+
// terminal receipt; NEVER overwrite them with the bounded preview held in
|
|
50
|
+
// stdout/stderr (a preview cannot reconstruct the full output).
|
|
51
|
+
for (const channel of ['stdout', 'stderr']) fsyncPrivate(join(directory, channel));
|
|
52
|
+
} else {
|
|
53
|
+
// Legacy: replace the incrementally-appended file with the complete
|
|
54
|
+
// in-memory captured bytes (capped at maxOutputBytes), exact old contract.
|
|
55
|
+
for (const [channel, text] of [['stdout', stdout], ['stderr', stderr]]) atomicPrivate(join(directory, channel), text);
|
|
56
|
+
}
|
|
57
|
+
const next = { ...record, ...metadata, status, exitCode, completedAt: Date.now() };
|
|
58
|
+
writeRecord(directory, next); record = next;
|
|
59
|
+
};
|
|
60
|
+
if (privateFile(join(directory, 'cancel'), true) || controller.signal.aborted) {
|
|
61
|
+
persist('canceled', null, '', ''); return;
|
|
62
|
+
}
|
|
63
|
+
record = { ...record, status: 'running', supervisorPid: process.pid }; writeRecord(directory, record);
|
|
64
|
+
stage = 'execute';
|
|
65
|
+
let outputError = false, stateError = false;
|
|
66
|
+
poll = setInterval(() => {
|
|
67
|
+
try { if (privateFile(join(directory, 'cancel'), true)) cancel(); }
|
|
68
|
+
catch { stateError = true; cancel(); }
|
|
69
|
+
}, 20);
|
|
70
|
+
const result = await runBoundedProcess({ argv: shellCommandArgv(request.command, request.shellPolicy ?? null), cwd: request.cwd,
|
|
71
|
+
env: request.env, timeoutMs: request.timeoutMs, settings: request.settings, outputPolicy: request.outputPolicy,
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
onOutput(channel, text) {
|
|
74
|
+
let fd;
|
|
75
|
+
try {
|
|
76
|
+
fd = openPrivate(join(directory, channel), constants.O_WRONLY | constants.O_APPEND); writeFileSync(fd, text);
|
|
77
|
+
} catch { outputError = true; cancel(); }
|
|
78
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
79
|
+
} });
|
|
80
|
+
clearInterval(poll); poll = null;
|
|
81
|
+
let status = result.exitCode === 0 ? 'completed' : 'failed', exitCode = result.exitCode, diagnostic = '';
|
|
82
|
+
if (result.canceled) status = 'canceled';
|
|
83
|
+
else if (result.timedOut) { status = 'timed_out'; exitCode = 124; diagnostic = 'command timed out'; }
|
|
84
|
+
else if (result.outputLimitExceeded) { status = 'output_limit'; exitCode = 122; diagnostic = 'command output limit exceeded'; }
|
|
85
|
+
commandEvidence = commandFailureEvidence(stage, { result, failures: [
|
|
86
|
+
...(outputError ? ['output_recording_failed'] : []), ...(stateError ? ['state_observation_failed'] : [])] });
|
|
87
|
+
if (result.controls.status === 'failed' || result.cleanupError || outputError || stateError) {
|
|
88
|
+
status = 'unknown'; exitCode = null; diagnostic = 'command supervision or cleanup failed';
|
|
89
|
+
}
|
|
90
|
+
// Under persisted-stream, result.stdout/stderr are bounded PREVIEWS; the full
|
|
91
|
+
// output lives in the streamed files. Append diagnostics to the stderr PREVIEW
|
|
92
|
+
// for the receipt only; the streamed stderr file is unchanged by this append.
|
|
93
|
+
const stderr = result.stderr + (diagnostic ? (result.stderr && !result.stderr.endsWith('\n') ? '\n' : '') + diagnostic : '');
|
|
94
|
+
stage = 'publish';
|
|
95
|
+
persist(status, exitCode, result.stdout, stderr, { controls: result.controls, outputLimitExceeded: result.outputLimitExceeded,
|
|
96
|
+
...(status === 'unknown' ? { commandEvidence } : {}),
|
|
97
|
+
evidenceIncomplete: status === 'unknown', observedExitCode: status === 'unknown' ? result.exitCode : null,
|
|
98
|
+
outcomeReason: status === 'unknown' ? 'process_supervision_failed' : '' });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Secrets/input exception strings never enter records or diagnostics.
|
|
101
|
+
if (record && !TERMINAL.has(record.status)) unknownRecord(directory, { ...record,
|
|
102
|
+
commandEvidence: commandFailureEvidence(stage, { previous: commandEvidence,
|
|
103
|
+
failures: [commandExceptionCause(error, 'supervisor_exception')] }) }, 'supervisor_failed');
|
|
104
|
+
} finally {
|
|
105
|
+
clearInterval(poll); process.off('SIGTERM', cancel); process.off('SIGINT', cancel); unlock();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
supervise().catch(() => { process.exitCode = 1; });
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Lifecycle substrate for Host-authorized commands, independent of disposable
|
|
2
|
+
// tool workers. Command/workspace policy and tool approvals are caller duties.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { constants, closeSync, existsSync, fsyncSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { localControlSettings, resolveLocalProcessLauncher } from './local-process.mjs';
|
|
10
|
+
import { localEnvironmentPolicy, localOutputPolicy } from './local-output-policy.mjs';
|
|
11
|
+
import { shellCommandArgv } from './command-shell.mjs';
|
|
12
|
+
import { LOCAL_BACKGROUND_CONTRACT, START_GRACE_MS, TERMINAL, LocalCommandError,
|
|
13
|
+
check, createPrivate, delay, digest, fsyncPrivate, lockUntil, openPrivate, privateDirectory, privateFile,
|
|
14
|
+
readAll, readRecord, readWindow, syncDirectory, tryLock, unknownRecord, validId, writeRecord } from './local-command-state.mjs';
|
|
15
|
+
|
|
16
|
+
export { LOCAL_BACKGROUND_CONTRACT, LocalCommandError };
|
|
17
|
+
const supervisor = fileURLToPath(new URL('./local-background-supervisor.mjs', import.meta.url));
|
|
18
|
+
const inside = (path, root) => path === root || path.startsWith(root + sep);
|
|
19
|
+
const workerEnvironment = Object.freeze({ PATH: '/bin:/usr/bin', LANG: 'C.UTF-8', NODE_NO_WARNINGS: '1' });
|
|
20
|
+
const deadline = () => performance.now() + START_GRACE_MS;
|
|
21
|
+
|
|
22
|
+
// commandRequest validates raw policy inputs at this public entrypoint. The
|
|
23
|
+
// policies travel as separate optional fields, NOT inside settings, so the
|
|
24
|
+
// normalized settings object (and thus the request digest) is byte-identical
|
|
25
|
+
// to legacy when no policy is supplied. Omit absent policies: canonical
|
|
26
|
+
// serialization rejects undefined, and the digest must stay deterministic.
|
|
27
|
+
export function commandRequest({ command, cwd, env, timeoutMs, settings = {}, outputPolicy = null, environmentPolicy = null, shellPolicy = null } = {}) {
|
|
28
|
+
settings = localControlSettings(settings);
|
|
29
|
+
const outPolicy = localOutputPolicy(outputPolicy);
|
|
30
|
+
const envPolicy = localEnvironmentPolicy(environmentPolicy);
|
|
31
|
+
check(typeof command === 'string' && command.length > 0 && !command.includes('\0')
|
|
32
|
+
&& typeof cwd === 'string' && cwd.length > 0 && !cwd.includes('\0'), 'invalid_background_command');
|
|
33
|
+
check(env && typeof env === 'object' && !Array.isArray(env) && Object.entries(env).every(([name, value]) =>
|
|
34
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && typeof value === 'string' && !value.includes('\0')), 'invalid_background_command_environment');
|
|
35
|
+
timeoutMs ??= settings.wallSeconds * 1000;
|
|
36
|
+
check(Number.isSafeInteger(timeoutMs) && timeoutMs > 0, 'invalid_background_command_timeout');
|
|
37
|
+
const request = { command, cwd: resolve(cwd), env: Object.fromEntries(Object.entries(env)), timeoutMs, settings };
|
|
38
|
+
if (outPolicy) request.outputPolicy = outPolicy;
|
|
39
|
+
if (envPolicy) request.environmentPolicy = envPolicy;
|
|
40
|
+
shellCommandArgv(command, shellPolicy);
|
|
41
|
+
if (shellPolicy) request.shellPolicy = shellPolicy;
|
|
42
|
+
check(Buffer.byteLength(JSON.stringify(request)) <= 1024 * 1024, 'background_command_request_too_large');
|
|
43
|
+
return request;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class LocalBackgroundCommand {
|
|
47
|
+
constructor(directory, owner, commandId) {
|
|
48
|
+
this.directory = directory; this.owner = owner; this.commandId = commandId;
|
|
49
|
+
readRecord(directory, owner, commandId); Object.freeze(this);
|
|
50
|
+
}
|
|
51
|
+
get processId() {
|
|
52
|
+
const record = readRecord(this.directory, this.owner, this.commandId);
|
|
53
|
+
check(Number.isSafeInteger(record.supervisorPid) && record.supervisorPid > 0, 'background_supervisor_identity_unavailable');
|
|
54
|
+
return record.supervisorPid; // Diagnostic locator only. Never used to cancel.
|
|
55
|
+
}
|
|
56
|
+
readOutput({ stdoutCursor = 0, stderrCursor = 0, maxBytes = 65536 } = {}) {
|
|
57
|
+
check(Number.isSafeInteger(maxBytes) && maxBytes >= 4 && maxBytes <= 65536, 'invalid_background_output_window');
|
|
58
|
+
privateDirectory(this.directory);
|
|
59
|
+
const release = tryLock(join(this.directory, 'supervisor.sqlite'));
|
|
60
|
+
let record;
|
|
61
|
+
try {
|
|
62
|
+
// Read the record while the ownership probe is held, so a final commit
|
|
63
|
+
// cannot be mistaken for a lost supervisor using an older snapshot.
|
|
64
|
+
record = readRecord(this.directory, this.owner, this.commandId);
|
|
65
|
+
if (release && !TERMINAL.has(record.status)
|
|
66
|
+
&& (record.status !== 'starting' || Date.now() - record.createdAt >= START_GRACE_MS))
|
|
67
|
+
record = { ...record, status: 'unknown', exitCode: null, evidenceIncomplete: true, outcomeReason: 'supervisor_lost' };
|
|
68
|
+
} finally { release?.(); }
|
|
69
|
+
const stdout = readWindow(join(this.directory, 'stdout'), stdoutCursor, maxBytes);
|
|
70
|
+
const stderr = readWindow(join(this.directory, 'stderr'), stderrCursor, maxBytes);
|
|
71
|
+
return { status: record.status, stdout: stdout.text, stderr: stderr.text,
|
|
72
|
+
stdoutCursor: stdout.cursor, stderrCursor: stderr.cursor, exitCode: TERMINAL.has(record.status) ? record.exitCode : null,
|
|
73
|
+
cancelRequested: privateFile(join(this.directory, 'cancel'), true) !== null,
|
|
74
|
+
outputMayBeTruncated: record.outputLimitExceeded === true || stdout.hasMore || stderr.hasMore,
|
|
75
|
+
evidenceIncomplete: record.evidenceIncomplete === true, observedExitCode: record.observedExitCode ?? null,
|
|
76
|
+
outcomeReason: record.outcomeReason ?? '',
|
|
77
|
+
...(record.commandEvidence ? { commandEvidence: record.commandEvidence } : {}) };
|
|
78
|
+
}
|
|
79
|
+
requestCancel() {
|
|
80
|
+
if (!TERMINAL.has(this.readOutput().status)) {
|
|
81
|
+
const fd = openPrivate(join(this.directory, 'cancel'), constants.O_WRONLY | constants.O_CREAT);
|
|
82
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
83
|
+
syncDirectory(this.directory);
|
|
84
|
+
}
|
|
85
|
+
return this.readOutput();
|
|
86
|
+
}
|
|
87
|
+
async wait({ timeoutMs = null, signal } = {}) {
|
|
88
|
+
check(timeoutMs === null || Number.isFinite(timeoutMs) && timeoutMs >= 0, 'invalid_background_wait_timeout');
|
|
89
|
+
const until = timeoutMs === null ? Infinity : performance.now() + timeoutMs;
|
|
90
|
+
for (;;) {
|
|
91
|
+
signal?.throwIfAborted(); const output = this.readOutput();
|
|
92
|
+
if (TERMINAL.has(output.status)) {
|
|
93
|
+
check(output.status !== 'unknown', 'background_command_outcome_unknown');
|
|
94
|
+
const stdout = readAll(join(this.directory, 'stdout')), stderr = readAll(join(this.directory, 'stderr'));
|
|
95
|
+
const record = readRecord(this.directory, this.owner, this.commandId);
|
|
96
|
+
return { ...output, stdout, stderr, stdoutCursor: Buffer.byteLength(stdout), stderrCursor: Buffer.byteLength(stderr),
|
|
97
|
+
outputMayBeTruncated: record.outputLimitExceeded === true };
|
|
98
|
+
}
|
|
99
|
+
check(performance.now() < until, 'background_command_still_running'); await delay(20);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async kill() {
|
|
103
|
+
this.requestCancel();
|
|
104
|
+
try { return (await this.wait({ timeoutMs: 3000 })).status === 'canceled'; }
|
|
105
|
+
catch (error) { if (error instanceof LocalCommandError) return false; throw error; }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class LocalCommandStore {
|
|
110
|
+
constructor({ stateRoot, workspaceRoot, sessionId }) {
|
|
111
|
+
check(process.platform === 'darwin' || process.platform === 'linux', 'unsupported_local_background_platform');
|
|
112
|
+
check(typeof stateRoot === 'string' && !!stateRoot && typeof workspaceRoot === 'string' && !!workspaceRoot
|
|
113
|
+
&& typeof sessionId === 'string' && !!sessionId.trim(), 'invalid_background_command_scope');
|
|
114
|
+
const root = resolve(stateRoot === '~' ? homedir() : stateRoot.startsWith('~/') ? join(homedir(), stateRoot.slice(2)) : stateRoot);
|
|
115
|
+
const workspace = realpathSync(workspaceRoot);
|
|
116
|
+
check(!inside(root, workspace), 'background_state_inside_workspace');
|
|
117
|
+
privateDirectory(root, true); privateDirectory(join(root, 'commands'), true);
|
|
118
|
+
this.owner = digest([workspace, 'local_process', sessionId]); this.directory = join(root, 'commands', this.owner); this.sessionId = sessionId;
|
|
119
|
+
privateDirectory(this.directory, true); Object.freeze(this);
|
|
120
|
+
}
|
|
121
|
+
commandId(operationKey) {
|
|
122
|
+
check(typeof operationKey === 'string' && !!operationKey.trim(), 'invalid_background_operation_key');
|
|
123
|
+
// The format is private/versioned; no historical Python data is imported.
|
|
124
|
+
return digest([this.owner, operationKey]);
|
|
125
|
+
}
|
|
126
|
+
connect(commandId) {
|
|
127
|
+
check(validId(commandId), 'invalid_background_command_identity');
|
|
128
|
+
return new LocalBackgroundCommand(join(this.directory, commandId), this.owner, commandId);
|
|
129
|
+
}
|
|
130
|
+
async start({ operationKey = randomUUID(), assertCurrent = () => true, ...input }) {
|
|
131
|
+
check(typeof assertCurrent === 'function', 'background_command_guard_invalid');
|
|
132
|
+
const guard = () => {
|
|
133
|
+
const allowed = assertCurrent();
|
|
134
|
+
if (allowed && typeof allowed.then === 'function') Promise.resolve(allowed).catch(() => {});
|
|
135
|
+
check(allowed === true, 'background_command_authority_lost');
|
|
136
|
+
};
|
|
137
|
+
const request = commandRequest(input), commandId = this.commandId(operationKey), requestDigest = digest(request);
|
|
138
|
+
const release = await lockUntil(join(this.directory, 'launch.sqlite'), deadline());
|
|
139
|
+
let handle;
|
|
140
|
+
try {
|
|
141
|
+
guard();
|
|
142
|
+
const directory = join(this.directory, commandId);
|
|
143
|
+
if (existsSync(directory)) {
|
|
144
|
+
handle = this.connect(commandId);
|
|
145
|
+
check(readRecord(directory, this.owner, commandId).requestDigest === requestDigest, 'background_operation_arguments_changed');
|
|
146
|
+
return handle; // An existing/unknown operation is never launched again.
|
|
147
|
+
}
|
|
148
|
+
resolveLocalProcessLauncher(); // New effects require a verified launcher.
|
|
149
|
+
const record = { schemaVersion: LOCAL_BACKGROUND_CONTRACT, owner: this.owner, commandId, requestDigest,
|
|
150
|
+
status: 'starting', createdAt: Date.now(), timeoutMs: request.timeoutMs, exitCode: null };
|
|
151
|
+
// A killed preparer may leave a private temporary directory, but cannot
|
|
152
|
+
// expose a half-created command handle. Only the complete, durable
|
|
153
|
+
// directory is published; there is no spawn before this publication.
|
|
154
|
+
const preparing = join(this.directory, `.preparing-${randomUUID()}`);
|
|
155
|
+
try {
|
|
156
|
+
privateDirectory(preparing, true);
|
|
157
|
+
for (const file of ['stdout', 'stderr', 'supervisor.sqlite']) createPrivate(join(preparing, file));
|
|
158
|
+
writeRecord(preparing, record);
|
|
159
|
+
renameSync(preparing, directory); syncDirectory(dirname(directory));
|
|
160
|
+
} finally { if (existsSync(preparing)) rmSync(preparing, { recursive: true, force: true }); }
|
|
161
|
+
// Intent is durable BEFORE the first spawn.
|
|
162
|
+
handle = this.connect(commandId);
|
|
163
|
+
guard();
|
|
164
|
+
let child;
|
|
165
|
+
try {
|
|
166
|
+
child = spawn(process.execPath, [supervisor, directory, this.owner, commandId], {
|
|
167
|
+
detached: true, cwd: directory, env: workerEnvironment, stdio: ['pipe', 'ignore', 'ignore'],
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
unknownRecord(directory, record, 'supervisor_launch_failed'); return handle;
|
|
171
|
+
}
|
|
172
|
+
child.once('error', () => {
|
|
173
|
+
// A spawn error proves no child was created; a pipe failure does not.
|
|
174
|
+
let unlock;
|
|
175
|
+
try {
|
|
176
|
+
unlock = tryLock(join(directory, 'supervisor.sqlite'));
|
|
177
|
+
const current = readRecord(directory, this.owner, commandId);
|
|
178
|
+
if (unlock && current.status === 'starting') unknownRecord(directory, current, 'supervisor_launch_failed');
|
|
179
|
+
} catch { /* existing durable intent remains recoverably unknown */ }
|
|
180
|
+
finally { unlock?.(); }
|
|
181
|
+
});
|
|
182
|
+
child.stdin.on('error', () => { /* return the stable locator; never retry */ });
|
|
183
|
+
// Secrets travel only through the private stdin pipe, never argv/record.
|
|
184
|
+
child.stdin.end(JSON.stringify(request)); child.unref();
|
|
185
|
+
} finally { release(); }
|
|
186
|
+
const until = performance.now() + START_GRACE_MS;
|
|
187
|
+
while (readRecord(handle.directory, this.owner, commandId).status === 'starting' && performance.now() < until) await delay(20);
|
|
188
|
+
return handle;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Reader for createBackgroundCommandPort's canonical, owned launch receipts.
|
|
193
|
+
* This performs no launch/cancel/record transition and never selects a backend
|
|
194
|
+
* from the environment. A missing supervisor is a derived unknown outcome.
|
|
195
|
+
*/
|
|
196
|
+
export function createLocalBackgroundReader(store) {
|
|
197
|
+
check(store instanceof LocalCommandStore, 'invalid_local_background_store');
|
|
198
|
+
return Object.freeze(async request => {
|
|
199
|
+
request.signal.throwIfAborted();
|
|
200
|
+
check(request.sessionId === store.sessionId && request.ref
|
|
201
|
+
&& request.ref.taskId === store.commandId(request.ref.operationKey), 'background_reader_owner_mismatch');
|
|
202
|
+
check(request.waitSeconds === 0, 'background_observation_must_not_wait');
|
|
203
|
+
const result = store.connect(request.ref.taskId).readOutput({ stdoutCursor: request.stdoutCursor,
|
|
204
|
+
stderrCursor: request.stderrCursor, maxBytes: request.maxBytes });
|
|
205
|
+
request.signal.throwIfAborted(); return result;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Bounded diagnostic facts only. Exception text, tool input and paths never
|
|
2
|
+
// enter these records, and an observed exit code is not a verified outcome.
|
|
3
|
+
export const COMMAND_EVIDENCE_CONTRACT = 'agentkit.local-command-evidence/v1';
|
|
4
|
+
const stages = new Set(['request', 'execute', 'publish', 'transport']);
|
|
5
|
+
const causes = new Set(['control_report_failed', 'process_cleanup_failed', 'output_recording_failed',
|
|
6
|
+
'state_observation_failed', 'private_state_invalid', 'authority_lost', 'request_changed',
|
|
7
|
+
'canceled', 'filesystem_failed', 'supervisor_exception', 'transport_exception']);
|
|
8
|
+
const cleanup = new Set(['spawn_observer_failed', 'stdout_read_error', 'stderr_read_error',
|
|
9
|
+
'control_report_read_error', 'control_report_oversize', 'owner_lease_error',
|
|
10
|
+
'process_reap_timeout', 'process_stdio_close_timeout', 'group_signal_failed', 'cleanup_issue_unclassified']);
|
|
11
|
+
const privateCodes = new Set(['background_state_not_private', 'background_state_symlink',
|
|
12
|
+
'background_state_changed', 'invalid_background_record']);
|
|
13
|
+
const filesystemCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EIO', 'ENOSPC', 'EMFILE', 'ENFILE', 'EROFS']);
|
|
14
|
+
|
|
15
|
+
/** Classify only explicitly recognized exception codes, never their text. */
|
|
16
|
+
export function commandExceptionCause(error, fallback) {
|
|
17
|
+
let code;
|
|
18
|
+
try { code = error?.code; } catch { return fallback; }
|
|
19
|
+
if (privateCodes.has(code)) return 'private_state_invalid';
|
|
20
|
+
if (filesystemCodes.has(code)) return 'filesystem_failed';
|
|
21
|
+
if (['local_command_authority_stale', 'background_command_authority_lost'].includes(code)) return 'authority_lost';
|
|
22
|
+
if (code === 'local_command_request_drifted') return 'request_changed';
|
|
23
|
+
if (code === 'local_command_signal_stale') return 'canceled';
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Combine observed failure facts without assigning a task execution verdict. */
|
|
28
|
+
export function commandFailureEvidence(stage, { result, failures = [], previous } = {}) {
|
|
29
|
+
if (!stages.has(stage)) throw Error('invalid_command_evidence_stage');
|
|
30
|
+
const reasons = new Set(previous?.causes ?? []), issues = new Set(previous?.cleanupIssues ?? []);
|
|
31
|
+
for (const value of failures) reasons.add(value);
|
|
32
|
+
if (result?.controls?.status === 'failed') reasons.add('control_report_failed');
|
|
33
|
+
if (result?.cleanupError) {
|
|
34
|
+
reasons.add('process_cleanup_failed');
|
|
35
|
+
for (const value of result.cleanupError.split(','))
|
|
36
|
+
issues.add(cleanup.has(value) ? value : /^group_SIG(?:TERM|KILL):/.test(value)
|
|
37
|
+
? 'group_signal_failed' : 'cleanup_issue_unclassified');
|
|
38
|
+
}
|
|
39
|
+
const observedExitCode = result?.exitCode ?? previous?.observedExitCode ?? null;
|
|
40
|
+
const evidence = { contract: COMMAND_EVIDENCE_CONTRACT, stage,
|
|
41
|
+
causes: [...reasons].sort(), cleanupIssues: [...issues].sort(), observedExitCode };
|
|
42
|
+
if (!validCommandEvidence(evidence)) throw Error('invalid_command_evidence');
|
|
43
|
+
return evidence;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Durable optional field: old records retain their absence of diagnostics. */
|
|
47
|
+
export function validCommandEvidence(value) {
|
|
48
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
49
|
+
&& Object.keys(value).length === 5
|
|
50
|
+
&& Object.keys(value).every(key => ['contract', 'stage', 'causes', 'cleanupIssues', 'observedExitCode'].includes(key))
|
|
51
|
+
&& value.contract === COMMAND_EVIDENCE_CONTRACT && stages.has(value.stage)
|
|
52
|
+
&& Array.isArray(value.causes) && value.causes.length <= causes.size
|
|
53
|
+
&& value.causes.every(item => causes.has(item)) && new Set(value.causes).size === value.causes.length
|
|
54
|
+
&& Array.isArray(value.cleanupIssues) && value.cleanupIssues.length <= cleanup.size
|
|
55
|
+
&& value.cleanupIssues.every(item => cleanup.has(item)) && new Set(value.cleanupIssues).size === value.cleanupIssues.length
|
|
56
|
+
&& (value.observedExitCode === null || Number.isSafeInteger(value.observedExitCode));
|
|
57
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { Workspace, PositionEncoding } from '@astral-sh/ruff-wasm-nodejs';
|
|
6
|
+
import { NATIVE_PYTHON_SYNTAX } from './python-syntax.mjs';
|
|
7
|
+
import { LOCAL_COMMAND_POLICY } from './local-command-policy.mjs';
|
|
8
|
+
import { analyzeShellSource } from './shell-analysis.mjs';
|
|
9
|
+
import { decodeRuffAst, ruffIdentifier, ruffNodes, ruffString, unwrapRuff } from './ruff-ast.mjs';
|
|
10
|
+
|
|
11
|
+
if (Workspace.version() !== NATIVE_PYTHON_SYNTAX.parserVersion) throw Error('command_policy_parser_version_mismatch');
|
|
12
|
+
const parser = new Workspace({ 'target-version': NATIVE_PYTHON_SYNTAX.targetVersion, lint: { select: [] } }, PositionEncoding.Utf32);
|
|
13
|
+
const blocked = new Set(['kill', 'killall', 'nohup', 'pkill', 'setsid']);
|
|
14
|
+
const suffixes = new Set(['.py', '.sh', '.bash']);
|
|
15
|
+
const subprocesses = new Set(['subprocess.call', 'subprocess.check_call', 'subprocess.check_output', 'subprocess.Popen', 'subprocess.run']);
|
|
16
|
+
const python = command => command === 'python' || command.startsWith('python3');
|
|
17
|
+
const shell = command => ['bash', 'dash', 'sh'].includes(command);
|
|
18
|
+
const finding = (rule, detail, path = '', line = null) => ({ rule, detail, path, line });
|
|
19
|
+
function limited(depth) { if (depth > 64) throw Error('command_policy_nesting_limit'); }
|
|
20
|
+
function expanded(path) {
|
|
21
|
+
if (path.startsWith('~') && path !== '~' && !path.startsWith('~/')) throw Error('command_policy_named_home_unsupported');
|
|
22
|
+
return path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
|
23
|
+
}
|
|
24
|
+
function weakRealpath(path, depth = 0) {
|
|
25
|
+
limited(depth); path = resolve(path);
|
|
26
|
+
try { return realpathSync(path); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
29
|
+
try { if (lstatSync(path).isSymbolicLink()) return weakRealpath(resolve(dirname(path), readlinkSync(path)), depth + 1); }
|
|
30
|
+
catch (nested) { if (nested.code !== 'ENOENT' && nested.code !== 'ENOTDIR') throw nested; }
|
|
31
|
+
if (dirname(path) === path) throw error;
|
|
32
|
+
return join(weakRealpath(dirname(path), depth + 1), basename(path));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const roots = workerData.allowedRoots.map(path => weakRealpath(expanded(path)));
|
|
36
|
+
const cwd = weakRealpath(workerData.cwd);
|
|
37
|
+
function inside(path) {
|
|
38
|
+
try { const target = weakRealpath(path); return roots.some(root => target === root || root === sep || target.startsWith(root + sep)); }
|
|
39
|
+
catch { return false; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Gateway restrictions and local risks consume the same parsed invocations.
|
|
43
|
+
// This deliberately retains the existing policy choices, without treating
|
|
44
|
+
// comments or heredoc data as command arguments.
|
|
45
|
+
function gatewayPolicy(args) {
|
|
46
|
+
for (let i = 0; i + 1 < args.length; i++) if (args[i] === 'rm'
|
|
47
|
+
&& args.slice(i + 1, i + 3).some(flag => flag.startsWith('-') && /[rR]/.test(flag)))
|
|
48
|
+
return finding('command_rejected', 'recursive rm is not allowed without explicit approval');
|
|
49
|
+
if (args.some(arg => ['sudo', 'kubectl', 'docker'].includes(arg)))
|
|
50
|
+
return finding('command_rejected', `${args[0]} command is not allowed by default policy`);
|
|
51
|
+
if (args[0] === 'git' && args.length > 1) {
|
|
52
|
+
if (['status', 'diff', 'log', 'show', 'branch', 'ls-files'].includes(args[1])) return null;
|
|
53
|
+
if (['reset', 'clean', 'push', 'checkout'].includes(args[1]))
|
|
54
|
+
return finding('command_rejected', `git ${args[1]} is not allowed without explicit approval`);
|
|
55
|
+
}
|
|
56
|
+
if (args.join(' ').includes('169.254.169.254') || args.join(' ').includes('metadata.google.internal'))
|
|
57
|
+
return finding('command_rejected', 'metadata/private endpoint access is not allowed by default policy');
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function executableIndex(args) {
|
|
61
|
+
return args.findIndex(arg => !/^[A-Za-z_][A-Za-z0-9_]*=.*$/.test(arg));
|
|
62
|
+
}
|
|
63
|
+
function recursiveForce(args) {
|
|
64
|
+
let options = '';
|
|
65
|
+
for (const arg of args) {
|
|
66
|
+
if ([';', '&&', '||', '|', '--'].includes(arg)) break;
|
|
67
|
+
if (arg.startsWith('-')) options += arg.replace(/^-+/, '');
|
|
68
|
+
}
|
|
69
|
+
return options.toLowerCase().includes('r') && options.toLowerCase().includes('f');
|
|
70
|
+
}
|
|
71
|
+
function checkTokens(args, path, line, depth, stdin = null) {
|
|
72
|
+
limited(depth);
|
|
73
|
+
const index = executableIndex(args); if (index < 0) return null;
|
|
74
|
+
const command = basename(args[index]).toLowerCase(), rest = args.slice(index + 1);
|
|
75
|
+
if (blocked.has(command)) return finding(`shell.blocked_command.${command}`, `command '${command}' is not allowed by the local-process policy`, path, line);
|
|
76
|
+
if (command === 'rm' && recursiveForce(rest)) return finding('shell.recursive_force_remove', 'recursive forced removal is not allowed', path, line);
|
|
77
|
+
if (stdin !== null && !rest.includes('-c') && !rest.includes('-m')
|
|
78
|
+
&& (!rest.length || rest[0] === '-' || shell(command) && rest.includes('-s'))) {
|
|
79
|
+
const result = python(command) ? checkPython(stdin, path || '<stdin>', depth + 1)
|
|
80
|
+
: shell(command) ? checkShell(stdin, path || '<stdin>', depth + 1, false, false, command === 'bash' ? 'bash' : 'posix') : null;
|
|
81
|
+
if (result) return result;
|
|
82
|
+
}
|
|
83
|
+
const option = rest.indexOf('-c');
|
|
84
|
+
if (option < 0 || option + 1 >= rest.length) return null;
|
|
85
|
+
return python(command) ? checkPython(rest[option + 1], path || '<command>', depth + 1)
|
|
86
|
+
: shell(command) ? checkShell(rest[option + 1], path, depth + 1, false, false, command === 'bash' ? 'bash' : 'posix') : null;
|
|
87
|
+
}
|
|
88
|
+
function checkShell(text, path, depth, governed = false, scanEntries = false, dialect = 'bash') {
|
|
89
|
+
limited(depth);
|
|
90
|
+
const analysis = analyzeShellSource(text);
|
|
91
|
+
if (analysis.hasParseErrors) return finding('invalid_command', 'invalid shell syntax', path);
|
|
92
|
+
if (dialect === 'posix' && analysis.hasBashCombinedRedirects)
|
|
93
|
+
return finding('invalid_command', 'Bash combined redirection requires an explicit Bash interpreter', path);
|
|
94
|
+
if (analysis.backgrounds.length) return finding('shell.background_process', 'background process syntax is not allowed', path, analysis.backgrounds[0]);
|
|
95
|
+
for (const { argv, line, stdin } of analysis.commands) {
|
|
96
|
+
const result = (governed ? gatewayPolicy(argv) : null) ?? checkTokens(argv, path, line, depth + 1, stdin)
|
|
97
|
+
?? (scanEntries ? scanScripts(argv) : null);
|
|
98
|
+
if (result) return result;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function callName(value, aliases) {
|
|
103
|
+
const node = unwrapRuff(value);
|
|
104
|
+
if (node?.tag === 'ExprName') { const name = ruffIdentifier(node.fields.id); return aliases.get(name) ?? name; }
|
|
105
|
+
if (node?.tag === 'ExprAttribute') {
|
|
106
|
+
const parent = callName(node.fields.value, aliases), attr = ruffIdentifier(node.fields.attr);
|
|
107
|
+
return parent ? `${parent}.${attr}` : attr;
|
|
108
|
+
}
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
function commandLiteral(value) {
|
|
112
|
+
const text = ruffString(value); if (text !== null) return text;
|
|
113
|
+
const node = unwrapRuff(value);
|
|
114
|
+
if (!['ExprList', 'ExprTuple'].includes(node?.tag)) return null;
|
|
115
|
+
const parts = node.fields.elts.map(ruffString);
|
|
116
|
+
return parts.every(part => part !== null) ? parts : null;
|
|
117
|
+
}
|
|
118
|
+
function keywordTrue(args, name) {
|
|
119
|
+
return args.keywords.some(item => ruffIdentifier(item.fields.arg) === name
|
|
120
|
+
&& unwrapRuff(item.fields.value)?.tag === 'ExprBooleanLiteral' && unwrapRuff(item.fields.value).fields.value === true);
|
|
121
|
+
}
|
|
122
|
+
function checkPython(source, path, depth) {
|
|
123
|
+
limited(depth); if (Buffer.byteLength(source) > 2 * 1024 * 1024) throw Error('command_policy_source_limit');
|
|
124
|
+
let wire;
|
|
125
|
+
try {
|
|
126
|
+
parser.comments(source); // Grammar only, matching ast.parse rather than compile.
|
|
127
|
+
if (parser.check(source).some(item => / on Python 3\.12 \(syntax was added in Python 3\.\d+\)$/.test(item.message))) return null;
|
|
128
|
+
wire = parser.parse(source);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (/ at byte range \d+\.\.\d+$/.test(error.message)) return null;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
const nodes = ruffNodes(decodeRuffAst(wire)), aliases = new Map(), bytes = Buffer.from(source);
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
if (node.tag === 'StmtImport' || node.tag === 'StmtImportFrom') {
|
|
136
|
+
const module = node.tag === 'StmtImportFrom' ? ruffIdentifier(node.fields.module) : '';
|
|
137
|
+
if (node.tag === 'StmtImportFrom' && !module) continue;
|
|
138
|
+
for (const alias of node.fields.names) {
|
|
139
|
+
const name = ruffIdentifier(alias.fields.name), asname = ruffIdentifier(alias.fields.asname);
|
|
140
|
+
aliases.set(asname ?? name, module ? `${module}.${name}` : name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const node of nodes) {
|
|
145
|
+
if (node.tag !== 'ExprCall') continue;
|
|
146
|
+
const name = callName(node.fields.func, aliases), args = node.fields.arguments.fields;
|
|
147
|
+
const line = bytes.subarray(0, node.fields.range.range[0]).toString('utf8').split(/\r\n|\r|\n/).length;
|
|
148
|
+
if (['os.kill', 'os.killpg', 'os.setsid'].includes(name)) return finding('python.process_control', `call to ${name} is not allowed`, path, line);
|
|
149
|
+
if (['os.popen', 'os.system'].includes(name) && args.args.length) {
|
|
150
|
+
const text = ruffString(args.args[0]);
|
|
151
|
+
if (text !== null) { const result = checkShell(text, path, depth + 1); if (result) return result; }
|
|
152
|
+
}
|
|
153
|
+
if (subprocesses.has(name)) {
|
|
154
|
+
if (name === 'subprocess.Popen' && keywordTrue(args, 'start_new_session'))
|
|
155
|
+
return finding('python.detached_process', 'subprocess start_new_session=True is not allowed', path, line);
|
|
156
|
+
const command = args.args.length ? commandLiteral(args.args[0]) : null;
|
|
157
|
+
const result = Array.isArray(command) ? checkTokens(command, path, line, depth + 1)
|
|
158
|
+
: typeof command === 'string' && keywordTrue(args, 'shell') ? checkShell(command, path, depth + 1) : null;
|
|
159
|
+
if (result) return result;
|
|
160
|
+
}
|
|
161
|
+
if (name === 'shutil.rmtree' && args.args.length) {
|
|
162
|
+
const value = ruffString(args.args[0]);
|
|
163
|
+
if (value !== null && !inside(resolve(cwd, expanded(value))))
|
|
164
|
+
return finding('path.destructive_outside_workspace', `shutil.rmtree target ${JSON.stringify(value)} is outside the allowed roots`, path, line);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function directScript(command, rest) {
|
|
170
|
+
if (suffixes.has(extname(command).toLowerCase())) return command;
|
|
171
|
+
if (!(python(basename(command).toLowerCase()) || shell(basename(command).toLowerCase())) || rest.includes('-c') || rest.includes('-m')) return null;
|
|
172
|
+
for (const arg of rest) {
|
|
173
|
+
if (arg === '--' || arg.startsWith('-')) continue;
|
|
174
|
+
return suffixes.has(extname(arg).toLowerCase()) ? arg : null;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
function scanScripts(args) {
|
|
179
|
+
const index = executableIndex(args); if (index < 0) return null;
|
|
180
|
+
const script = directScript(args[index], args.slice(index + 1)); if (script === null) return null;
|
|
181
|
+
let path, source;
|
|
182
|
+
try {
|
|
183
|
+
path = realpathSync(isAbsolute(script) ? script : resolve(cwd, script));
|
|
184
|
+
if (!statSync(path).isFile() || !inside(path)) return null;
|
|
185
|
+
if (statSync(path).size > 2 * 1024 * 1024) throw Error('command_policy_source_limit');
|
|
186
|
+
source = new TextDecoder('utf8', { fatal: true, ignoreBOM: true }).decode(readFileSync(path));
|
|
187
|
+
} catch (error) { if (error.message === 'command_policy_source_limit') throw error; return null; }
|
|
188
|
+
return extname(path).toLowerCase() === '.py' ? checkPython(source, path, 0)
|
|
189
|
+
: checkShell(source, path, 0, false, false, ['sh', 'dash'].includes(basename(args[index])) ? 'posix' : 'bash');
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const { command } = workerData;
|
|
193
|
+
const required = workerData.governed && (typeof command !== 'string' || !command.trim())
|
|
194
|
+
? finding('command_required', 'command is required') : null;
|
|
195
|
+
parentPort.postMessage({ contract: LOCAL_COMMAND_POLICY, finding: required ?? (typeof command === 'string'
|
|
196
|
+
? checkShell(command, '', 0, workerData.governed, true) : checkTokens(command, '', null, 0) ?? scanScripts(command)) });
|
|
197
|
+
} finally { parser.free(); parentPort.close(); }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
export { LOCAL_COMMAND_SHELL } from './command-shell.mjs';
|
|
3
|
+
|
|
4
|
+
export const LOCAL_COMMAND_POLICY = 'agentkit.local-command-policy/v2';
|
|
5
|
+
export const GOVERNED_COMMAND_POLICY = 'agentkit.governed-command-policy/v2';
|
|
6
|
+
const failure = code => Object.assign(new Error(code), { code });
|
|
7
|
+
|
|
8
|
+
/** Same-container risk checks, not an OS sandbox. Approval and workspace
|
|
9
|
+
* ownership are Host duties. Source is parsed/read in a bounded Node worker;
|
|
10
|
+
* no Python interpreter or workspace module is imported or executed.
|
|
11
|
+
*/
|
|
12
|
+
export const checkLocalCommand = (command, options) => checkCommand(command, options, false);
|
|
13
|
+
export const checkGovernedCommand = (command, options) => checkCommand(command, options, true);
|
|
14
|
+
async function checkCommand(command, { cwd, allowedRoots, signal, timeoutMs = 10000 } = {}, governed) {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if (!(typeof command === 'string' || Array.isArray(command) && command.every(item => typeof item === 'string'))
|
|
17
|
+
|| Buffer.byteLength(JSON.stringify(command)) > 2 * 1024 * 1024 || typeof cwd !== 'string'
|
|
18
|
+
|| !Array.isArray(allowedRoots) || !allowedRoots.length || allowedRoots.some(root => typeof root !== 'string'))
|
|
19
|
+
throw failure('invalid_command_policy_input');
|
|
20
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10000) throw failure('invalid_command_policy_deadline');
|
|
21
|
+
let worker;
|
|
22
|
+
try { worker = new Worker(new URL('./local-command-policy-worker.mjs', import.meta.url), {
|
|
23
|
+
workerData: { command, cwd, allowedRoots, governed }, execArgv: [],
|
|
24
|
+
resourceLimits: { maxOldGenerationSizeMb: 128, maxYoungGenerationSizeMb: 16, stackSizeMb: 4 },
|
|
25
|
+
}); } catch { throw failure('command_policy_unavailable'); }
|
|
26
|
+
return await new Promise((resolve, reject) => {
|
|
27
|
+
let output, error, stopping = false;
|
|
28
|
+
const stop = reason => { if (stopping) return; stopping = true; error = reason; void worker.terminate().catch(() => {}); };
|
|
29
|
+
const abort = () => stop(signal.reason ?? failure('command_policy_canceled'));
|
|
30
|
+
const timer = setTimeout(() => stop(failure('command_policy_timeout')), timeoutMs);
|
|
31
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
32
|
+
worker.once('message', value => { output = value; });
|
|
33
|
+
worker.once('error', () => stop(failure('command_policy_unavailable')));
|
|
34
|
+
worker.once('exit', code => {
|
|
35
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
36
|
+
if (error) return reject(error);
|
|
37
|
+
const finding = output?.finding;
|
|
38
|
+
if (code !== 0 || output?.contract !== LOCAL_COMMAND_POLICY || !(finding === null || finding
|
|
39
|
+
&& typeof finding.rule === 'string' && finding.rule && typeof finding.detail === 'string'
|
|
40
|
+
&& typeof finding.path === 'string' && (finding.line === null || Number.isSafeInteger(finding.line) && finding.line > 0)))
|
|
41
|
+
return reject(failure('command_policy_unavailable'));
|
|
42
|
+
resolve(finding);
|
|
43
|
+
});
|
|
44
|
+
if (signal?.aborted) abort();
|
|
45
|
+
});
|
|
46
|
+
}
|