chati-dev 4.5.28 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +117 -12
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, copyFileSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
6
|
+
import { validateBrowserObservationPlan } from '@chati/planning';
|
|
7
|
+
export { validateBrowserObservationPlan } from '@chati/planning';
|
|
8
|
+
import { browserRuntimeLayout, copyObserverRuntime, observerRuntimeDigest } from './browser-runtime.js';
|
|
9
|
+
import { prepareExecutableSnapshot, assertExecutableSnapshot, disposeExecutableSnapshot } from './executable-snapshot.js';
|
|
10
|
+
import { runIsolatedBrowserSupervisor } from './spawner.js';
|
|
11
|
+
import { runDarwinBrowserObservation } from './darwin-browser-observer.js';
|
|
12
|
+
import { browserObservationCompleted } from './browser-process-facts.js';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
const bindingFor = (path) => ({ executable_path: realpathSync(path), executable_sha256: digest(readFileSync(path)) });
|
|
17
|
+
function fail(message, code = 'BROWSER_OBSERVATION_INVALID') { throw Object.assign(new Error(message), { code }); }
|
|
18
|
+
function text(value, limit = 8192) { return typeof value === 'string' && value.length > 0 && !value.includes('\0') && Buffer.byteLength(value) <= limit; }
|
|
19
|
+
|
|
20
|
+
function cleanupResources(runtime, browserImage, application) {
|
|
21
|
+
let cleanupError;
|
|
22
|
+
for (const cleanup of [() => { if (runtime) rmSync(runtime, { recursive: true, force: true }); },
|
|
23
|
+
() => { if (browserImage) disposeExecutableSnapshot(browserImage); },
|
|
24
|
+
() => { if (application) disposeExecutableSnapshot(application); }]) {
|
|
25
|
+
try { cleanup(); } catch (error) { cleanupError ??= error; }
|
|
26
|
+
}
|
|
27
|
+
if (cleanupError) throw cleanupError;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Real local observation only. It does not select a commit, approve a policy,
|
|
31
|
+
* retain artifacts or sign evidence. The canonical RAIL observer owns those gates.
|
|
32
|
+
*/
|
|
33
|
+
export async function runIsolatedBrowserObservation(input = {}) {
|
|
34
|
+
if (!input || typeof input !== 'object' || !text(input.workingDir) || !isAbsolute(input.workingDir)) fail('browser requires an absolute disposable subject');
|
|
35
|
+
const { workingDir, ...planInput } = input;
|
|
36
|
+
const plan = structuredClone(validateBrowserObservationPlan(planInput));
|
|
37
|
+
if (process.platform === 'darwin' ? plan.application.listener_transport !== 'darwin_prebound_tcp_v1'
|
|
38
|
+
: Object.hasOwn(plan.application, 'listener_transport')) fail('application listener transport does not match this host', 'BROWSER_APPLICATION_LISTENER_TRANSPORT_REQUIRED');
|
|
39
|
+
const project = realpathSync(workingDir);
|
|
40
|
+
const nodeBinding = bindingFor(process.execPath);
|
|
41
|
+
let runtime; let application; let browserImage;
|
|
42
|
+
try {
|
|
43
|
+
const packagePath = require.resolve('playwright-core/package.json');
|
|
44
|
+
if (JSON.parse(readFileSync(packagePath, 'utf8')).version !== plan.browser.playwright_version) fail('installed Playwright does not match the approved driver version', 'BROWSER_DRIVER_DRIFT');
|
|
45
|
+
application = prepareExecutableSnapshot(plan.application.executable_binding, { projectDir: project, requireNativeImage: true });
|
|
46
|
+
browserImage = prepareExecutableSnapshot(plan.browser.executable_binding, { projectDir: project, requireNativeImage: true });
|
|
47
|
+
runtime = mkdtempSync(join(realpathSync(tmpdir()), 'chati-browser-observer-tools-'));
|
|
48
|
+
copyObserverRuntime(dirname(packagePath), join(runtime, 'playwright-core'), project);
|
|
49
|
+
copyFileSync(new URL('./browser-observer-driver.js', import.meta.url), join(runtime, 'observer-driver.mjs'));
|
|
50
|
+
const layout = browserRuntimeLayout(plan.browser.executable_binding.executable_path);
|
|
51
|
+
copyObserverRuntime(layout.source, join(runtime, 'chromium', layout.directory), project, { internalLinks: layout.internalLinks });
|
|
52
|
+
// Replace the launch image with bytes copied through the already pinned fd.
|
|
53
|
+
const browserPath = join(runtime, 'chromium', layout.executable);
|
|
54
|
+
const browserStat = lstatSync(browserPath);
|
|
55
|
+
if (!browserStat.isFile() || browserStat.nlink !== 1) fail('copied browser launch image must be a single regular file');
|
|
56
|
+
copyFileSync(browserImage.executablePath, browserPath);
|
|
57
|
+
copyFileSync(application.executablePath, join(runtime, 'application'));
|
|
58
|
+
chmodSync(join(runtime, 'application'), 0o500);
|
|
59
|
+
assertExecutableSnapshot(application); assertExecutableSnapshot(browserImage);
|
|
60
|
+
const runtime_digest = observerRuntimeDigest(runtime);
|
|
61
|
+
const config = { ...plan, working_dir: project, browser_file: layout.executable };
|
|
62
|
+
let facts; let darwin;
|
|
63
|
+
if (process.platform === 'darwin') {
|
|
64
|
+
darwin = await runDarwinBrowserObservation({ workingDir: project, runtimeDirectory: runtime,
|
|
65
|
+
browserFile: layout.executable, nodeBinding, plan });
|
|
66
|
+
facts = darwin.facts;
|
|
67
|
+
} else {
|
|
68
|
+
const supervisor = readFileSync(new URL('./browser-observer-supervisor.js', import.meta.url), 'utf8');
|
|
69
|
+
facts = await runIsolatedBrowserSupervisor({ workingDir: project, executableBinding: nodeBinding,
|
|
70
|
+
args: ['--input-type=module'], stdin: `const configuration = ${JSON.stringify(config)};\n${supervisor}`,
|
|
71
|
+
timeoutMs: plan.timeout_ms, observerDirectory: runtime });
|
|
72
|
+
}
|
|
73
|
+
if (observerRuntimeDigest(runtime) !== runtime_digest) fail('observer runtime changed during observation', 'BROWSER_RUNTIME_DRIFT');
|
|
74
|
+
const process_facts = Object.fromEntries(Object.entries(facts).filter(([key]) => !['stdout', 'stderr', 'stdout_base64', 'stderr_base64'].includes(key)));
|
|
75
|
+
const base = { schema_version: 1, evidence_class: 'real-browser-observation', tool: 'chati-playwright-api-v1',
|
|
76
|
+
runtime_digest, node_binding: nodeBinding, application_binding: plan.application.executable_binding,
|
|
77
|
+
browser_binding: plan.browser.executable_binding, playwright_version: plan.browser.playwright_version, process_facts,
|
|
78
|
+
...(darwin ? { darwin_browser_binding: darwin.binding, application_stopped: false,
|
|
79
|
+
application_group_stopped: darwin.binding.application.process_group_stopped } : {}) };
|
|
80
|
+
if (facts.exit_code !== 0 || facts.signal !== null || facts.timed_out || facts.output_limit_exceeded || facts.input_error) {
|
|
81
|
+
return Object.freeze({ ...base, passed: false, error_code: facts.timed_out ? 'BROWSER_OBSERVATION_TIMEOUT' : 'BROWSER_OBSERVER_PROCESS_FAILED', steps: [], artifacts: [] });
|
|
82
|
+
}
|
|
83
|
+
let observed;
|
|
84
|
+
try { observed = JSON.parse(facts.stdout); } catch { fail('trusted browser observer returned invalid output', 'BROWSER_OBSERVER_OUTPUT_INVALID'); }
|
|
85
|
+
// Subprocess output describes the scenario, never the host's private
|
|
86
|
+
// process bindings. In particular it cannot replace Darwin app completion.
|
|
87
|
+
const result = { ...observed, ...base };
|
|
88
|
+
if (!browserObservationCompleted(result, { workingDir: project, ...plan })) {
|
|
89
|
+
return Object.freeze({ ...base, passed: false, error_code: 'BROWSER_OBSERVER_PROCESS_FAILED', steps: [], artifacts: [] });
|
|
90
|
+
}
|
|
91
|
+
return Object.freeze(result);
|
|
92
|
+
} finally {
|
|
93
|
+
cleanupResources(runtime, browserImage, application);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Typed browser completion, not a certificate or native authentication source.
|
|
2
|
+
* The canonical caller obtains these facts only from its private observer. */
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { sha256 } from '@chati/core';
|
|
8
|
+
import { verificationProcessCompleted } from './verification-process-facts.js';
|
|
9
|
+
|
|
10
|
+
function invalid() { throw Object.assign(new Error('browser requires bound observer, application and endpoint completion'), { code: 'BROWSER_COMPLETION_INVALID' }); }
|
|
11
|
+
function canonicalPath(value) { return typeof value === 'string' && isAbsolute(value) && resolve(value) === value
|
|
12
|
+
&& !Array.from(value).some(character => character.codePointAt(0) < 32 || character.codePointAt(0) === 127); }
|
|
13
|
+
function contains(root, child) { const part = relative(root, child); return !part || (part !== '..' && !part.startsWith('../') && !isAbsolute(part)); }
|
|
14
|
+
const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
15
|
+
|
|
16
|
+
export function darwinBrowserObserverInvocation({ runtimeDirectory, workingDir, browserFile, proxyPort, nodeBinding, plan }) {
|
|
17
|
+
if (!canonicalPath(runtimeDirectory) || !canonicalPath(workingDir) || typeof browserFile !== 'string'
|
|
18
|
+
|| isAbsolute(browserFile) || browserFile.split('/').some(part => !part || part === '.' || part === '..')
|
|
19
|
+
|| !Number.isInteger(proxyPort) || proxyPort < 1024 || proxyPort > 65535) invalid();
|
|
20
|
+
const config = { hostname: plan.hostname, application: { port: plan.application.port }, timeout_ms: plan.timeout_ms, steps: plan.steps };
|
|
21
|
+
const options = { executablePath: join(runtimeDirectory, 'chromium', browserFile),
|
|
22
|
+
proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '<-loopback>' },
|
|
23
|
+
args: ['--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1', '--use-mock-keychain'] };
|
|
24
|
+
const stdin = `import { chromium } from ${JSON.stringify(pathToFileURL(join(runtimeDirectory, 'playwright-core/index.mjs')).href)};
|
|
25
|
+
import { observeBrowserScenario } from ${JSON.stringify(pathToFileURL(join(runtimeDirectory, 'observer-driver.mjs')).href)};
|
|
26
|
+
const observed = await observeBrowserScenario({ chromium, config: ${JSON.stringify(config)}, launchOptions: ${JSON.stringify(options)}, isApplicationAlive: () => true });
|
|
27
|
+
process.stdout.write(JSON.stringify(observed));\n`;
|
|
28
|
+
// The host's private application lifetime signal aborts this process on app
|
|
29
|
+
// death; the driver does not pretend to authenticate another process itself.
|
|
30
|
+
return { workingDir, executableBinding: nodeBinding, args: ['--input-type=module'], stdin, timeoutMs: plan.timeout_ms };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function browserObservationCompleted(observed, expected) {
|
|
34
|
+
if (observed?.process_facts?.containment_kind !== 'darwin_scoped_process_group_v1') {
|
|
35
|
+
return observed?.process_facts?.containment_kind === 'bubblewrap_pid_namespace_v1'
|
|
36
|
+
&& observed.process_facts.process_tree_stopped === true && observed.application_stopped === true
|
|
37
|
+
&& !Object.hasOwn(observed, 'darwin_browser_binding');
|
|
38
|
+
}
|
|
39
|
+
const binding = observed.darwin_browser_binding;
|
|
40
|
+
const fields = ['schema_version', 'runtime_directory', 'observer_working_dir', 'browser_file', 'proxy_port', 'base_bundle_id', 'application'];
|
|
41
|
+
if (!binding || Object.keys(binding).length !== fields.length || fields.some(key => !Object.hasOwn(binding, key))
|
|
42
|
+
|| binding.schema_version !== 1 || !canonicalPath(expected?.workingDir)
|
|
43
|
+
|| !canonicalPath(binding.runtime_directory) || !canonicalPath(binding.observer_working_dir)
|
|
44
|
+
|| [binding.runtime_directory, binding.observer_working_dir].some(path => contains(path, expected.workingDir) || contains(expected.workingDir, path))
|
|
45
|
+
|| contains(binding.runtime_directory, binding.observer_working_dir) || contains(binding.observer_working_dir, binding.runtime_directory)
|
|
46
|
+
|| typeof binding.base_bundle_id !== 'string' || binding.base_bundle_id.length > 200
|
|
47
|
+
|| !/^(?:[A-Za-z0-9-]+\.)+[A-Za-z0-9-]+$/.test(binding.base_bundle_id)
|
|
48
|
+
|| expected.application?.listener_transport !== 'darwin_prebound_tcp_v1'
|
|
49
|
+
|| observed.application_stopped !== false || observed.application_group_stopped !== true) invalid();
|
|
50
|
+
const nodeBinding = { executable_path: realpathSync(process.execPath),
|
|
51
|
+
executable_sha256: createHash('sha256').update(readFileSync(process.execPath)).digest('hex') };
|
|
52
|
+
if (sha256(observed.node_binding) !== sha256(nodeBinding)
|
|
53
|
+
|| sha256(observed.application_binding) !== sha256(expected.application.executable_binding)
|
|
54
|
+
|| sha256(observed.browser_binding) !== sha256(expected.browser.executable_binding)) invalid();
|
|
55
|
+
const invocation = darwinBrowserObserverInvocation({ runtimeDirectory: binding.runtime_directory,
|
|
56
|
+
workingDir: binding.observer_working_dir, browserFile: binding.browser_file,
|
|
57
|
+
proxyPort: binding.proxy_port, nodeBinding, plan: expected });
|
|
58
|
+
if (!verificationProcessCompleted(observed.process_facts, invocation)
|
|
59
|
+
|| observed.process_facts.exit_code !== 0 || observed.process_facts.signal !== null
|
|
60
|
+
|| observed.process_facts.timed_out !== false || observed.process_facts.output_limit_exceeded !== false
|
|
61
|
+
|| observed.process_facts.input_error !== null) invalid();
|
|
62
|
+
const app = binding.application;
|
|
63
|
+
if (!app || app.listener_transport !== expected.application.listener_transport
|
|
64
|
+
|| !verificationProcessCompleted(app, { workingDir: expected.workingDir, executableBinding: expected.application.executable_binding,
|
|
65
|
+
args: expected.application.args, stdin: expected.application.stdin }) || app.error_code !== null || app.signal !== null
|
|
66
|
+
|| app.timed_out !== false || app.output_limit_exceeded !== false || app.input_error !== null || app.requested_stop !== true
|
|
67
|
+
|| app.darwin_verification_binding.completion.supervisor_signal !== 15
|
|
68
|
+
|| !(app.darwin_verification_binding.completion.signal === 15 || app.darwin_verification_binding.completion.exit_code === 0)
|
|
69
|
+
|| app.endpoint_proof?.nonce !== app.darwin_verification_binding.ready.nonce || app.endpoint_proof.closed !== true
|
|
70
|
+
|| !hash(app.endpoint_proof.proof_chain_sha256) || !Number.isSafeInteger(app.endpoint_proof.positive_proofs)
|
|
71
|
+
|| !Number.isSafeInteger(app.proxy?.connections_proved) || app.proxy.connections_proved < 2
|
|
72
|
+
|| app.endpoint_proof.positive_proofs < app.proxy.connections_proved || app.proxy.owner_rejections !== 0
|
|
73
|
+
|| app.proxy.policy_violations !== 0 || app.proxy.closed !== true || app.proxy.timed_out !== false || app.proxy.active_connections !== 0) invalid();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Private tool assembly. Never copy the application workspace into the observer.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { cpSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
6
|
+
import { sha256 } from '@chati/core';
|
|
7
|
+
import { installationAuthorityStateDirectory } from '../installer-v2/installation-authority.js';
|
|
8
|
+
|
|
9
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
function fail(message) { throw Object.assign(new Error(message), { code: 'BROWSER_OBSERVATION_INVALID' }); }
|
|
11
|
+
function inside(root, target) { const part = relative(root, target); return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)); }
|
|
12
|
+
|
|
13
|
+
export function browserRuntimeLayout(executable, platform = process.platform) {
|
|
14
|
+
if (platform === 'darwin' && executable.split(sep).some(part => part.endsWith('.app'))) {
|
|
15
|
+
const source = dirname(dirname(dirname(executable)));
|
|
16
|
+
if (basename(dirname(executable)) !== 'MacOS' || basename(dirname(dirname(executable))) !== 'Contents'
|
|
17
|
+
|| !basename(source).endsWith('.app')) fail('unsupported browser app bundle layout');
|
|
18
|
+
return { source, directory: basename(source), executable: join(basename(source), relative(source, executable)), internalLinks: true };
|
|
19
|
+
}
|
|
20
|
+
return { source: dirname(executable), directory: '', executable: basename(executable), internalLinks: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseDarwinBrowserBundleInfo(info, executableName) {
|
|
24
|
+
if (!info || info.CFBundlePackageType !== 'APPL' || info.CFBundleExecutable !== executableName
|
|
25
|
+
|| typeof info.CFBundleIdentifier !== 'string' || info.CFBundleIdentifier.length > 200
|
|
26
|
+
|| !/^(?:[A-Za-z0-9-]+\.)+[A-Za-z0-9-]+$/.test(info.CFBundleIdentifier)) {
|
|
27
|
+
fail('Darwin browser requires the intact main Chromium application bundle identity');
|
|
28
|
+
}
|
|
29
|
+
return info.CFBundleIdentifier;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Qualified Darwin arm64 Playwright Chromium153.0.8010.12 headless artifact.
|
|
33
|
+
// BaseBundleID is a compiled rendezvous namespace, NOT its codesign identifier
|
|
34
|
+
// or CFBundleIdentifier (this flat image has no Info.plist). Future artifacts
|
|
35
|
+
// need qualification; neither a filename nor a caller-supplied hash is trust.
|
|
36
|
+
const DARWIN_HEADLESS_NAMESPACES = Object.freeze({
|
|
37
|
+
a0bfe7b4da4787b66058477d696cd1d09065d25f06a548947722b9af77ee8282: 'org.chromium.Chromium',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export function darwinHeadlessRendezvousNamespace(executable, executableSha256) {
|
|
41
|
+
if (browserRuntimeLayout(executable, 'darwin').internalLinks || basename(executable) !== 'chrome-headless-shell'
|
|
42
|
+
|| !Object.hasOwn(DARWIN_HEADLESS_NAMESPACES, executableSha256)) {
|
|
43
|
+
fail('Darwin headless browser artifact has no qualified rendezvous namespace');
|
|
44
|
+
}
|
|
45
|
+
return DARWIN_HEADLESS_NAMESPACES[executableSha256];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readDarwinBrowserRendezvousNamespace(executable) {
|
|
49
|
+
if (process.platform !== 'darwin') fail('Darwin bundle inspection requires the actual Mac');
|
|
50
|
+
const layout = browserRuntimeLayout(executable, 'darwin');
|
|
51
|
+
if (!layout.internalLinks) {
|
|
52
|
+
const stat = lstatSync(executable);
|
|
53
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024 * 1024) fail('headless browser must be a bounded regular copied image');
|
|
54
|
+
return darwinHeadlessRendezvousNamespace(executable, digest(readFileSync(executable)));
|
|
55
|
+
}
|
|
56
|
+
const infoPath = join(layout.source, 'Contents', 'Info.plist');
|
|
57
|
+
const stat = lstatSync(infoPath);
|
|
58
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 65536) fail('browser bundle metadata must be a bounded regular copied file');
|
|
59
|
+
const contents = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', infoPath], {
|
|
60
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 65536, env: { PATH: '/usr/bin:/bin' }, stdio: ['ignore', 'pipe', 'pipe'],
|
|
61
|
+
});
|
|
62
|
+
return parseDarwinBrowserBundleInfo(JSON.parse(contents), basename(executable));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function internalLink(root, path) {
|
|
66
|
+
const link = readlinkSync(path);
|
|
67
|
+
if (!link || isAbsolute(link)) fail('observer runtime link must be relative and internal');
|
|
68
|
+
let resolved;
|
|
69
|
+
try { resolved = realpathSync(path); } catch { fail('observer runtime link is dangling or cyclic'); }
|
|
70
|
+
if (!inside(root, resolved) || inside(resolved, path)) fail('observer runtime link escapes its bundle or forms an ancestor cycle');
|
|
71
|
+
const target = lstatSync(resolved);
|
|
72
|
+
if (!target.isFile() && !target.isDirectory()) fail('observer runtime link targets a special file');
|
|
73
|
+
return { link, resolved, directory: target.isDirectory() };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function copyObserverRuntime(source, target, project, { internalLinks = false } = {}) {
|
|
77
|
+
const physical = realpathSync(source);
|
|
78
|
+
const authority = installationAuthorityStateDirectory();
|
|
79
|
+
if (inside(project, physical) || inside(physical, project)
|
|
80
|
+
|| inside(authority, physical) || inside(physical, authority)) fail('observer runtime cannot overlap tested application code or private authority storage');
|
|
81
|
+
let bytes = 0; let files = 0;
|
|
82
|
+
cpSync(physical, target, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true, filter(path) {
|
|
83
|
+
const stat = lstatSync(path);
|
|
84
|
+
if (stat.isSymbolicLink() && internalLinks) internalLink(physical, path);
|
|
85
|
+
else if (!stat.isDirectory() && !stat.isFile()) fail('observer runtime must not contain symlinks or special files');
|
|
86
|
+
files += 1; bytes += stat.isFile() ? stat.size : 0;
|
|
87
|
+
if (files > 20000 || bytes > 1024 * 1024 * 1024) fail('observer runtime exceeds the bounded tool-copy budget');
|
|
88
|
+
return true;
|
|
89
|
+
} });
|
|
90
|
+
// Recheck the copy itself before any process can execute from it. Relative
|
|
91
|
+
// framework links must resolve within the copied bundle, never the source.
|
|
92
|
+
observerRuntimeDigest(target);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function observerRuntimeDigest(path) {
|
|
96
|
+
path = realpathSync(path);
|
|
97
|
+
const entries = [];
|
|
98
|
+
const directories = new Map();
|
|
99
|
+
function visit(directory) {
|
|
100
|
+
const edges = []; directories.set(directory, edges);
|
|
101
|
+
for (const name of readdirSync(directory).sort()) {
|
|
102
|
+
const file = join(directory, name); const stat = lstatSync(file);
|
|
103
|
+
if (stat.isDirectory()) { edges.push(file); visit(file); }
|
|
104
|
+
else if (stat.isFile() && stat.nlink === 1) entries.push({ path: relative(path, file), mode: stat.mode & 0o777, sha256: digest(readFileSync(file)) });
|
|
105
|
+
else if (stat.isSymbolicLink()) {
|
|
106
|
+
const link = internalLink(path, file);
|
|
107
|
+
entries.push({ path: relative(path, file), kind: 'symlink', target: link.link });
|
|
108
|
+
if (link.directory) edges.push(link.resolved);
|
|
109
|
+
}
|
|
110
|
+
else fail('observer runtime copy changed identity');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
visit(path);
|
|
114
|
+
// A/B -> B and B/A -> A are individually resolvable internal links, yet
|
|
115
|
+
// together create an unbounded traversal. Check the directory graph without
|
|
116
|
+
// following links while copying/hashing; shared DAG destinations are valid.
|
|
117
|
+
const active = new Set(); const completed = new Set();
|
|
118
|
+
const stack = [{ directory: path, index: 0 }]; active.add(path);
|
|
119
|
+
while (stack.length) {
|
|
120
|
+
const frame = stack.at(-1); const edges = directories.get(frame.directory);
|
|
121
|
+
if (!edges) fail('observer runtime directory target changed identity');
|
|
122
|
+
if (frame.index === edges.length) {
|
|
123
|
+
active.delete(frame.directory); completed.add(frame.directory); stack.pop(); continue;
|
|
124
|
+
}
|
|
125
|
+
const child = edges[frame.index++];
|
|
126
|
+
if (active.has(child)) fail('observer runtime contains a directory cycle');
|
|
127
|
+
if (completed.has(child)) continue;
|
|
128
|
+
active.add(child); stack.push({ directory: child, index: 0 });
|
|
129
|
+
}
|
|
130
|
+
return sha256(entries);
|
|
131
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** Scoped Darwin authority policy. Kernel acceptance and effective permissions
|
|
2
|
+
* must be exercised on macOS; generating SBPL is not containment evidence. */
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { PROVIDER_CONTROL_PLANE_DIRECTORIES } from '@chati/core';
|
|
5
|
+
export { PROVIDER_CONTROL_PLANE_DIRECTORIES };
|
|
6
|
+
function fail(message) { throw Object.assign(new Error(message), { code: 'DARWIN_AUTHORITY_POLICY_INVALID' }); }
|
|
7
|
+
function path(value) {
|
|
8
|
+
if (typeof value !== 'string' || !isAbsolute(value) || resolve(value) !== value
|
|
9
|
+
|| Buffer.byteLength(value) > 900
|
|
10
|
+
|| Array.from(value).some(character => character.codePointAt(0) < 32 || character.codePointAt(0) === 127)) fail('policy paths must be bounded canonical absolute paths');
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function quote(value) { return JSON.stringify(path(value)); }
|
|
14
|
+
function contains(root, candidate) {
|
|
15
|
+
const entry = relative(root, candidate);
|
|
16
|
+
return !entry || (entry !== '..' && !entry.startsWith('../') && !isAbsolute(entry));
|
|
17
|
+
}
|
|
18
|
+
function overlaps(left, right) { return contains(left, right) || contains(right, left); }
|
|
19
|
+
const literal = value => `(literal ${quote(value)})`;
|
|
20
|
+
const subtree = value => `(subpath ${quote(value)})`;
|
|
21
|
+
function rule(action, operations, filters) {
|
|
22
|
+
if (!filters.length) return '';
|
|
23
|
+
return `(${action} ${operations} ${[...new Set(filters)].join(' ')})\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Explicit runtime roots only. In particular, /System would also expose the
|
|
27
|
+
// writable Data volume. No operator home, provider state, socket or credential
|
|
28
|
+
// service is made available to builder-controlled verification scripts.
|
|
29
|
+
const VERIFICATION_SYSTEM_ROOTS = Object.freeze(['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/lib', '/usr/share',
|
|
30
|
+
'/System/Library', '/Library/Apple/System/Library', '/System/Cryptexes', '/System/Volumes/Preboot/Cryptexes']);
|
|
31
|
+
|
|
32
|
+
// Application role only. This never grants outbound access, even to the trusted
|
|
33
|
+
// proxy. The observer has its own sandbox and private writable runtime.
|
|
34
|
+
export function darwinBrowserApplicationProfile({ applicationPort, observerDirectory, ...inputs }) {
|
|
35
|
+
if (!Number.isInteger(applicationPort) || applicationPort < 1024 || applicationPort > 65535) fail('browser application requires its frozen unprivileged port');
|
|
36
|
+
path(observerDirectory);
|
|
37
|
+
if (observerDirectory === '/' || [...VERIFICATION_SYSTEM_ROOTS, inputs.project, inputs.temporaryDirectory,
|
|
38
|
+
inputs.snapshotDirectory, inputs.launcherDirectory, inputs.authorityDirectory].some(value => overlaps(path(value), observerDirectory))) {
|
|
39
|
+
fail('browser observer tools must be separate from application and system roots');
|
|
40
|
+
}
|
|
41
|
+
const profile = darwinVerificationProfile({ ...inputs, maskedReadPaths: [...(inputs.maskedReadPaths ?? []), observerDirectory] })
|
|
42
|
+
// The already-bound fd11 is the capability. No new bind or implicit listen
|
|
43
|
+
// is allowed: localhost in SBPL also matches wildcard bind on actual XNU.
|
|
44
|
+
+ `(allow network-inbound (local tcp "localhost:${applicationPort}"))\n`;
|
|
45
|
+
if (Buffer.byteLength(profile) > 65536) fail('browser application policy exceeds its bounded native profile size');
|
|
46
|
+
return profile;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Observer role only. The host derives Chromium's BaseBundleID namespace from
|
|
50
|
+
// its runtime (qualified headless artifact or main application bundle), never
|
|
51
|
+
// from an application-selected Mach name, environment variable or plan field.
|
|
52
|
+
export function darwinBrowserObserverProfile({ proxyPort, observerDirectory, applicationProject, baseBundleId, ...inputs }) {
|
|
53
|
+
if (!Number.isInteger(proxyPort) || proxyPort < 1024 || proxyPort > 65535
|
|
54
|
+
|| typeof baseBundleId !== 'string' || baseBundleId.length > 200
|
|
55
|
+
|| !/^(?:[A-Za-z0-9-]+\.)+[A-Za-z0-9-]+$/.test(baseBundleId)) fail('browser observer requires its private proxy and sealed bundle identity');
|
|
56
|
+
const roots = [...VERIFICATION_SYSTEM_ROOTS, inputs.project, inputs.temporaryDirectory,
|
|
57
|
+
inputs.snapshotDirectory, inputs.launcherDirectory, inputs.authorityDirectory].map(path);
|
|
58
|
+
for (const value of [observerDirectory, applicationProject]) {
|
|
59
|
+
if (path(value) === '/' || roots.some(other => overlaps(other, value))) fail('observer and application roots must be separate from private/system roots');
|
|
60
|
+
}
|
|
61
|
+
if (overlaps(observerDirectory, applicationProject)) fail('observer tools cannot overlap application code');
|
|
62
|
+
const protectedPaths = [observerDirectory];
|
|
63
|
+
for (let parent = dirname(observerDirectory); ; parent = dirname(parent)) {
|
|
64
|
+
protectedPaths.push(parent);
|
|
65
|
+
if (parent === dirname(parent)) break;
|
|
66
|
+
}
|
|
67
|
+
let profile = darwinVerificationProfile({ ...inputs, maskedReadPaths: [...(inputs.maskedReadPaths ?? []), applicationProject] });
|
|
68
|
+
profile += rule('allow', 'file-read* file-map-executable', [subtree(observerDirectory)]);
|
|
69
|
+
profile += `(allow network-outbound (remote tcp "localhost:${proxyPort}"))\n`;
|
|
70
|
+
// Anonymous socketpair IPC only: no pathname bind/connect rule is added.
|
|
71
|
+
profile += '(allow system-socket (socket-domain AF_UNIX))\n';
|
|
72
|
+
// Chromium's inner sandbox limits lookup to the actual browser PID. Its
|
|
73
|
+
// rendezvous server selects registered child ports by kernel audit token.
|
|
74
|
+
// This outer regex is not itself per-invocation identity authentication.
|
|
75
|
+
// #"..." is an SBPL regex literal, not a JSON-decoded string. Literal-dot
|
|
76
|
+
// character classes avoid duplicating the escape layer at this boundary.
|
|
77
|
+
const rendezvous = `^${baseBundleId.replaceAll('.', '[.]')}[.]MachPortRendezvousServer[.][1-9][0-9]*$`;
|
|
78
|
+
profile += `(allow mach-register mach-lookup (global-name-regex #${JSON.stringify(rendezvous)}))\n`;
|
|
79
|
+
profile += '(allow mach-lookup (global-name "com.apple.logd") (global-name "com.apple.system.logger") (global-name "com.apple.system.opendirectoryd.libinfo") (global-name "com.apple.system.opendirectoryd.membership") (global-name "com.apple.FontObjectsServer") (global-name "com.apple.fonts"))\n';
|
|
80
|
+
// Chromium registers its native power monitor during startup. This exact
|
|
81
|
+
// class capability is NOT read-only: a compromised observer could affect
|
|
82
|
+
// host sleep/energy. It belongs to the immutable observer family (Node,
|
|
83
|
+
// driver, browser and descendants), never to the separate application.
|
|
84
|
+
// Chromium's own child sandbox remains enabled; no global OS setting changes.
|
|
85
|
+
profile += '(allow iokit-open-user-client (iokit-user-client-class "RootDomainUserClient"))\n';
|
|
86
|
+
profile += '(allow file-read-data (literal "/private/etc/passwd"))\n';
|
|
87
|
+
profile += rule('deny', 'file-read* file-map-executable', ['/Library/Keychains', '/System/Library/Keychains'].map(subtree));
|
|
88
|
+
profile += rule('deny', 'file-write*', [subtree(observerDirectory), ...protectedPaths.map(literal)]);
|
|
89
|
+
if (Buffer.byteLength(profile) > 65536) fail('browser observer policy exceeds its bounded native profile size');
|
|
90
|
+
return profile;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function darwinVerificationProfile({ project, temporaryDirectory, snapshotDirectory, launcherDirectory,
|
|
94
|
+
launcherPath, executablePath, authorityDirectory, maskedReadPaths = [], subjectReadOnly = false }) {
|
|
95
|
+
if (typeof subjectReadOnly !== 'boolean') fail('verification subject access must be an explicit boolean');
|
|
96
|
+
const roots = [project, temporaryDirectory, snapshotDirectory, launcherDirectory, authorityDirectory];
|
|
97
|
+
roots.forEach(value => { if (path(value) === '/') fail('verification roots must not be the filesystem root'); });
|
|
98
|
+
for (const [index, value] of roots.entries()) {
|
|
99
|
+
if (roots.slice(index + 1).some(other => overlaps(value, other))
|
|
100
|
+
|| VERIFICATION_SYSTEM_ROOTS.some(other => overlaps(value, other))) fail('verification roots must be separate from each other and system runtime roots');
|
|
101
|
+
}
|
|
102
|
+
if (!contains(launcherDirectory, path(launcherPath)) || !contains(snapshotDirectory, path(executablePath))) fail('verification executables must belong to sealed snapshots');
|
|
103
|
+
if (!Array.isArray(maskedReadPaths)) fail('verification read masks must be explicit');
|
|
104
|
+
const masked = [...new Set(maskedReadPaths.map(path))];
|
|
105
|
+
if (masked.some(value => value === '/' || roots.slice(0, 4).some(root => overlaps(value, root)))) fail('verification masks overlap required runtime roots');
|
|
106
|
+
const controlPlane = PROVIDER_CONTROL_PLANE_DIRECTORIES.map(name => join(project, name));
|
|
107
|
+
const protectedTrees = [snapshotDirectory, launcherDirectory, authorityDirectory, ...controlPlane, ...masked];
|
|
108
|
+
const ancestors = new Set([project, temporaryDirectory]);
|
|
109
|
+
for (const target of [...roots, ...protectedTrees]) {
|
|
110
|
+
for (let parent = dirname(target); ; parent = dirname(parent)) {
|
|
111
|
+
ancestors.add(parent);
|
|
112
|
+
if (parent === dirname(parent)) break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let profile = '(version 1)\n(deny default)\n';
|
|
116
|
+
profile += '(allow process-fork process-exec)\n(allow process-info* (target same-sandbox))\n(allow signal (target same-sandbox))\n';
|
|
117
|
+
profile += '(allow file-read-metadata)\n';
|
|
118
|
+
// dyld opens the root directory while locating its shared cache. This literal
|
|
119
|
+
// grant permits directory enumeration only, not descendant reads or writes.
|
|
120
|
+
profile += '(allow file-read-data (literal "/"))\n';
|
|
121
|
+
profile += rule('allow', 'file-read* file-map-executable', [...VERIFICATION_SYSTEM_ROOTS,
|
|
122
|
+
project, temporaryDirectory, snapshotDirectory, launcherDirectory].map(subtree));
|
|
123
|
+
profile += '(allow file-read-data (literal "/dev/random") (literal "/dev/urandom"))\n';
|
|
124
|
+
profile += '(allow file-ioctl file-read-data file-write-data (literal "/dev/null"))\n';
|
|
125
|
+
// uv_os_uname reads this public kernel version; Node imports node:os while
|
|
126
|
+
// loading other builtins and cannot initialize when this exact query fails.
|
|
127
|
+
profile += '(allow sysctl-read (sysctl-name "kern.version"))\n';
|
|
128
|
+
profile += '(allow sysctl-read (sysctl-name-prefix "hw.") (sysctl-name "kern.ostype") (sysctl-name "kern.osrelease") (sysctl-name "kern.osversion") (sysctl-name "kern.osproductversion") (sysctl-name "kern.maxfilesperproc") (sysctl-name "kern.argmax") (sysctl-name "kern.hostname") (sysctl-name "kern.tcsm_available") (sysctl-name "kern.tcsm_enable"))\n';
|
|
129
|
+
profile += rule('allow', 'file-write*', (subjectReadOnly ? [temporaryDirectory] : [project, temporaryDirectory]).map(subtree));
|
|
130
|
+
profile += rule('deny', 'file-read* file-map-executable', [authorityDirectory, ...controlPlane, ...masked].map(subtree));
|
|
131
|
+
profile += rule('deny', 'file-write*', [...protectedTrees.map(subtree), ...[...ancestors].map(literal)]);
|
|
132
|
+
if (Buffer.byteLength(profile) > 65536) fail('verification policy exceeds its bounded native profile size');
|
|
133
|
+
return profile;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function darwinAuthorityProfile({
|
|
137
|
+
project, stateDirectory, temporaryDirectory, snapshotDirectory, launcherDirectory,
|
|
138
|
+
launcherPath, executablePath, supportingExecutablePaths = [], authorityDirectory,
|
|
139
|
+
promptDirectory, writeScopes, maskedReadPaths = [], councilToolIsolation = false, homeDirectory,
|
|
140
|
+
}) {
|
|
141
|
+
const roots = { project, stateDirectory, temporaryDirectory, snapshotDirectory, launcherDirectory, authorityDirectory, homeDirectory };
|
|
142
|
+
for (const [key, value] of Object.entries(roots)) if (path(value) === '/') fail(`${key} must not be the filesystem root`);
|
|
143
|
+
path(launcherPath); path(executablePath);
|
|
144
|
+
if (!contains(launcherDirectory, launcherPath) || !contains(snapshotDirectory, executablePath)) fail('native executables must belong to their sealed snapshots');
|
|
145
|
+
if (!Array.isArray(supportingExecutablePaths) || supportingExecutablePaths.some(value => !contains(snapshotDirectory, path(value)))) fail('supporting executables must belong to the provider snapshot');
|
|
146
|
+
if (typeof councilToolIsolation !== 'boolean' || !Array.isArray(maskedReadPaths)
|
|
147
|
+
|| !writeScopes || typeof writeScopes.fullProject !== 'boolean' || !Array.isArray(writeScopes.entries)) fail('explicit scoped policy inputs are required');
|
|
148
|
+
if (writeScopes.fullProject && writeScopes.entries.length) fail('full-project and partial write scopes cannot be combined');
|
|
149
|
+
// Exclude accidental broad grants around the private authority and snapshots.
|
|
150
|
+
const privateRoots = [stateDirectory, temporaryDirectory, snapshotDirectory, launcherDirectory, authorityDirectory];
|
|
151
|
+
for (let index = 0; index < privateRoots.length; index++) {
|
|
152
|
+
if (overlaps(project, privateRoots[index])) fail('private runtime roots cannot overlap the project');
|
|
153
|
+
for (const other of privateRoots.slice(index + 1)) if (overlaps(privateRoots[index], other)) fail('private runtime roots must be distinct');
|
|
154
|
+
}
|
|
155
|
+
const controlPlane = PROVIDER_CONTROL_PLANE_DIRECTORIES.map(name => join(project, name));
|
|
156
|
+
const scopedWrites = writeScopes.fullProject ? [subtree(project)] : writeScopes.entries.map(entry => {
|
|
157
|
+
path(entry?.target);
|
|
158
|
+
if (!['file', 'directory'].includes(entry.kind) || entry.target === project || !contains(project, entry.target)
|
|
159
|
+
|| controlPlane.some(value => overlaps(value, entry.target))) fail('write scope escapes the product tree or overlaps its control plane');
|
|
160
|
+
return entry.kind === 'file' ? literal(entry.target) : subtree(entry.target);
|
|
161
|
+
});
|
|
162
|
+
const masked = [...new Set(maskedReadPaths.map(path))];
|
|
163
|
+
for (const denied of masked) {
|
|
164
|
+
if (denied === '/' || overlaps(denied, project) || privateRoots.slice(0, 4).some(value => overlaps(denied, value))) {
|
|
165
|
+
fail('masked paths must not overlap project or required private runtime roots');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (promptDirectory) {
|
|
169
|
+
path(promptDirectory);
|
|
170
|
+
if (privateRoots.some(value => overlaps(value, promptDirectory)) || overlaps(project, promptDirectory)) fail('private prompt must be separate from writable/private runtime roots');
|
|
171
|
+
}
|
|
172
|
+
const protectedTrees = [...controlPlane, snapshotDirectory, launcherDirectory, authorityDirectory,
|
|
173
|
+
...masked, ...(promptDirectory ? [promptDirectory] : [])];
|
|
174
|
+
const ancestors = new Set([project, stateDirectory, temporaryDirectory]);
|
|
175
|
+
for (const target of [...protectedTrees, ...writeScopes.entries.map(entry => entry.target)]) {
|
|
176
|
+
for (let parent = dirname(target); ; parent = dirname(parent)) {
|
|
177
|
+
ancestors.add(parent);
|
|
178
|
+
if (parent === dirname(parent)) break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const executableFilters = [launcherPath, executablePath, ...supportingExecutablePaths].map(literal);
|
|
182
|
+
let profile = '(version 1)\n(deny default)\n';
|
|
183
|
+
profile += '(allow process-fork)\n(allow process-info* (target same-sandbox))\n(allow signal (target same-sandbox))\n';
|
|
184
|
+
// No task-port grant: the provider must not inspect/modify its supervisor.
|
|
185
|
+
profile += councilToolIsolation ? rule('allow', 'process-exec', executableFilters) : '(allow process-exec)\n';
|
|
186
|
+
profile += '(allow file-read* file-map-executable)\n';
|
|
187
|
+
profile += '(allow sysctl-read (sysctl-name-prefix "hw.") (sysctl-name "kern.ostype") (sysctl-name "kern.osrelease") (sysctl-name "kern.osversion") (sysctl-name "kern.osproductversion") (sysctl-name "kern.maxfilesperproc") (sysctl-name "kern.argmax") (sysctl-name "kern.hostname") (sysctl-name "kern.tcsm_available") (sysctl-name "kern.tcsm_enable"))\n';
|
|
188
|
+
profile += '(allow sysctl-write (sysctl-name "kern.tcsm_enable"))\n';
|
|
189
|
+
profile += '(allow sysctl-read (sysctl-name "kern.version"))\n';
|
|
190
|
+
profile += '(allow mach-lookup (global-name "com.apple.SecurityServer") (global-name "com.apple.securityd.xpc") (global-name "com.apple.trustd.agent") (global-name "com.apple.system.opendirectoryd.libinfo") (global-name "com.apple.system.opendirectoryd.membership") (global-name "com.apple.system.logger") (global-name "com.apple.logd"))\n';
|
|
191
|
+
// IP transport is allowed for providers and product tests. Host Unix sockets,
|
|
192
|
+
// Apple Events, unrestricted Mach services and privileged task ports are not.
|
|
193
|
+
profile += '(allow network-outbound (remote ip "*:*"))\n(allow network-bind network-inbound (local ip "*:*"))\n';
|
|
194
|
+
// Apple's DNS client uses this system-owned Unix endpoint. This does not
|
|
195
|
+
// grant SSH-agent, Docker, arbitrary XPC, or other host socket access.
|
|
196
|
+
profile += '(allow system-socket (socket-domain AF_UNIX))\n(allow network-outbound (remote unix-socket (literal "/private/var/run/mDNSResponder")))\n';
|
|
197
|
+
profile += '(allow file-ioctl file-read-data file-write-data (literal "/dev/null"))\n';
|
|
198
|
+
profile += rule('allow', 'file-write*', [...scopedWrites, subtree(stateDirectory), subtree(temporaryDirectory)]);
|
|
199
|
+
if (councilToolIsolation) {
|
|
200
|
+
const homes = [...new Set(['/Users', '/var/root', '/private/var/root', homeDirectory])].map(subtree);
|
|
201
|
+
const visible = [project, stateDirectory, temporaryDirectory, snapshotDirectory, launcherDirectory,
|
|
202
|
+
...(promptDirectory ? [promptDirectory] : [])];
|
|
203
|
+
profile += rule('deny', 'file-read* file-map-executable', [
|
|
204
|
+
`(require-all (require-any ${homes.join(' ')}) ${visible.map(value => `(require-not ${subtree(value)})`).join(' ')})`,
|
|
205
|
+
...['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/local/bin', '/usr/local/sbin', '/opt/homebrew/bin', '/opt/homebrew/sbin'].map(subtree),
|
|
206
|
+
]);
|
|
207
|
+
}
|
|
208
|
+
// Denials remain last. A later write allow must not reopen a protected path.
|
|
209
|
+
profile += rule('deny', 'file-read* file-map-executable', [authorityDirectory, ...masked].map(subtree));
|
|
210
|
+
profile += rule('deny', 'file-write*', [...protectedTrees.map(subtree), ...[...ancestors].map(literal)]);
|
|
211
|
+
if (Buffer.byteLength(profile) > 65536) fail('scoped policy exceeds its bounded native profile size');
|
|
212
|
+
return profile;
|
|
213
|
+
}
|