@akagilnc/pi-workflow-roles 0.1.3541 → 0.1.3549
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/grok/production-host.js +26 -430
- package/dist/public-cli/main.js +24 -41
- package/package.json +1 -1
- package/src/doctor-evidence.ts +1 -8
- package/src/grok/production-host.ts +13 -216
- package/src/grok/role-turn-host.ts +4 -358
- package/src/host-transition-prior-native.ts +23 -32
- package/src/public-cli/post-admission.ts +0 -1
- package/src/grok/bash-seatbelt.ts +0 -69
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { copyFile, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, join, relative as pathRelative } from "node:path";
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
4
2
|
import { createInterface } from "node:readline";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
3
|
|
|
7
4
|
import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
|
|
8
5
|
import { renderAgentStartMaterials } from "../agent-start-materials.ts";
|
|
9
|
-
import { installGrokPreToolUseDeny } from "./bash-seatbelt.ts";
|
|
10
6
|
|
|
11
7
|
/** ACP v1 surface used by the Grok adapter. Protocol details stay in this module. */
|
|
12
8
|
export interface GrokAcpConnection {
|
|
@@ -17,13 +13,6 @@ export interface GrokAcpConnection {
|
|
|
17
13
|
close(): Promise<void>;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
export type GrokControlledInspection = Readonly<{
|
|
21
|
-
/** Active configuration whose source is neither Grok builtin nor AK injection. */
|
|
22
|
-
privateActive: readonly string[];
|
|
23
|
-
/** Active AK-owned configuration observed by the same first-party inspect call. */
|
|
24
|
-
akActive: readonly string[];
|
|
25
|
-
}>;
|
|
26
|
-
|
|
27
16
|
/** The shared envelope, prepared before session/new (systemPrompt delivery) and
|
|
28
17
|
* able to observe the host's real builtin tool surface once it arrives post-session. */
|
|
29
18
|
export type GrokPreparedTurn = Readonly<{
|
|
@@ -63,11 +52,6 @@ export function renderGrokSystemPromptOverride(authority: {
|
|
|
63
52
|
return renderAgentStartMaterials(authority.body, authority.materials);
|
|
64
53
|
}
|
|
65
54
|
|
|
66
|
-
export type GrokCapabilityDeclaration = Readonly<{
|
|
67
|
-
nativeToolNarrowing: false;
|
|
68
|
-
preToolUseDeny: boolean;
|
|
69
|
-
}>;
|
|
70
|
-
|
|
71
55
|
export type GrokSessionIdentityAuthority = Readonly<{
|
|
72
56
|
load(principal: RoleTurnRequest["principal"]): Promise<string | undefined>;
|
|
73
57
|
bind(principal: RoleTurnRequest["principal"], sessionId: string): Promise<void>;
|
|
@@ -78,9 +62,7 @@ export type GrokSessionIdentityAuthority = Readonly<{
|
|
|
78
62
|
export type GrokRoleTurnHostConfig = Readonly<{
|
|
79
63
|
sessionIdentity: GrokSessionIdentityAuthority;
|
|
80
64
|
connect(request: RoleTurnRequest): Promise<GrokAcpConnection>;
|
|
81
|
-
inspect(request: RoleTurnRequest): Promise<GrokControlledInspection>;
|
|
82
65
|
prepare(request: RoleTurnRequest): Promise<GrokPreparedTurn>;
|
|
83
|
-
recordCapabilities(request: RoleTurnRequest, declaration: GrokCapabilityDeclaration): void | Promise<void>;
|
|
84
66
|
}>;
|
|
85
67
|
|
|
86
68
|
function failure(cause: "activation" | "session" | "output", name: string, code: string, details?: Readonly<Record<string, unknown>>): RoleTurnResult {
|
|
@@ -223,21 +205,13 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
|
|
|
223
205
|
return {
|
|
224
206
|
executeTurn(request) {
|
|
225
207
|
const execution = serial.then(async (): Promise<RoleTurnResult> => {
|
|
226
|
-
const inspected = await config.inspect(request);
|
|
227
|
-
if (inspected.privateActive.length !== 0) {
|
|
228
|
-
return failure("activation", "UncontrolledGrokSession", "private-config-active", {
|
|
229
|
-
privateActive: [...inspected.privateActive],
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
208
|
const continuation = request.continuation;
|
|
233
209
|
const prepared = await config.prepare(request);
|
|
234
210
|
let connection: GrokAcpConnection | undefined;
|
|
235
211
|
let sessionId: string | undefined;
|
|
236
212
|
let accepted = false;
|
|
237
213
|
try {
|
|
238
|
-
// AK injection proof is prepared MCP composition (envelope)
|
|
239
|
-
// Inspect only classifies first-party already-active sources; external packageRoot
|
|
240
|
-
// materials reach session/new via prepare, not via Grok-native inspect paths.
|
|
214
|
+
// AK injection proof is prepared MCP composition (envelope).
|
|
241
215
|
if (prepared.mcpServers.length === 0) {
|
|
242
216
|
return failure("activation", "UncontrolledGrokSession", "ak-config-missing");
|
|
243
217
|
}
|
|
@@ -249,20 +223,6 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
|
|
|
249
223
|
const initializeMeta = initialized._meta as {
|
|
250
224
|
modelState?: { availableModels?: unknown };
|
|
251
225
|
} | undefined;
|
|
252
|
-
const hookMeta = initialized._meta as { "x.ai/hooks"?: { blockingEvents?: unknown; decisions?: unknown } } | undefined;
|
|
253
|
-
const hookCapability = hookMeta?.["x.ai/hooks"];
|
|
254
|
-
const canDeny = Array.isArray(hookCapability?.blockingEvents)
|
|
255
|
-
&& hookCapability.blockingEvents.includes("pre_tool_use")
|
|
256
|
-
&& Array.isArray(hookCapability.decisions)
|
|
257
|
-
&& hookCapability.decisions.includes("deny");
|
|
258
|
-
// ADR 0008: seatbelt hangs only on the activated Fixer. Capability alone
|
|
259
|
-
// is not an installed belt, and review seats (ADR 0064) must stay unnarrowed.
|
|
260
|
-
let preToolUseDeny = false;
|
|
261
|
-
if (canDeny && request.activation.role === "fixer") {
|
|
262
|
-
await installGrokPreToolUseDeny(request.home);
|
|
263
|
-
preToolUseDeny = true;
|
|
264
|
-
}
|
|
265
|
-
await config.recordCapabilities(request, { nativeToolNarrowing: false, preToolUseDeny });
|
|
266
226
|
const modelState = initializeMeta?.modelState;
|
|
267
227
|
const availableModels = Array.isArray(modelState?.availableModels) ? modelState.availableModels : undefined;
|
|
268
228
|
if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
|
|
@@ -436,325 +396,11 @@ const PRIVATE_COMPAT_ENV = Object.fromEntries(
|
|
|
436
396
|
[`GROK_${vendor}_${kind}_ENABLED`, "false"] as const)),
|
|
437
397
|
);
|
|
438
398
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
readonly path?: unknown;
|
|
442
|
-
readonly disabled?: unknown;
|
|
443
|
-
readonly enabled?: unknown;
|
|
444
|
-
readonly compatibilityStatus?: unknown;
|
|
445
|
-
readonly source?: { readonly type?: unknown; readonly path?: unknown };
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
export type GrokInspectionClassificationOptions = Readonly<{
|
|
449
|
-
/**
|
|
450
|
-
* Calling-repo projectInstructions whose path is carried by HEAD and whose
|
|
451
|
-
* working-tree bytes match that blob (#521 repo-instructions-are-shared-material).
|
|
452
|
-
* These are shared repo material — not privateActive, not AK package injection.
|
|
453
|
-
*/
|
|
454
|
-
readonly headMatchedProjectInstructionPaths?: ReadonlySet<string>;
|
|
455
|
-
}>;
|
|
456
|
-
|
|
457
|
-
const execFileAsync = promisify(execFile);
|
|
458
|
-
|
|
459
|
-
function errnoCode(error: unknown): string | undefined {
|
|
460
|
-
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
|
|
461
|
-
const code = (error as { code: unknown }).code;
|
|
462
|
-
return typeof code === "string" ? code : undefined;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
async function realpathIfPresent(path: string): Promise<string | undefined> {
|
|
466
|
-
try {
|
|
467
|
-
return await realpath(path);
|
|
468
|
-
} catch (error) {
|
|
469
|
-
if (errnoCode(error) === "ENOENT") return undefined;
|
|
470
|
-
throw error;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Typed worktree readability: ENOENT → absent; permission and other IO stay loud.
|
|
476
|
-
* Does not consult Git diagnostics.
|
|
477
|
-
*/
|
|
478
|
-
async function worktreeFilePresence(path: string): Promise<"absent" | "present"> {
|
|
479
|
-
try {
|
|
480
|
-
const handle = await open(path, "r");
|
|
481
|
-
await handle.close();
|
|
482
|
-
return "present";
|
|
483
|
-
} catch (error) {
|
|
484
|
-
if (errnoCode(error) === "ENOENT") return "absent";
|
|
485
|
-
throw error;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Map a worktree-relative path to the unique HEAD tree path it names.
|
|
491
|
-
* Exact match first; otherwise a single case-insensitive hit in the same
|
|
492
|
-
* directory (Grok may report `Claude.md` while HEAD stores `CLAUDE.md`).
|
|
493
|
-
* Path identity keeps the inspect leaf name (final symlink not followed).
|
|
494
|
-
* Git/IO failures propagate; only "not in HEAD" returns undefined.
|
|
495
|
-
*/
|
|
496
|
-
async function resolveHeadTreePath(topLevel: string, relativePath: string): Promise<string | undefined> {
|
|
497
|
-
const { stdout: exactOut } = await execFileAsync(
|
|
498
|
-
"git",
|
|
499
|
-
["ls-tree", "--name-only", "HEAD", "--", relativePath],
|
|
500
|
-
{ cwd: topLevel, encoding: "utf8" },
|
|
501
|
-
);
|
|
502
|
-
const exactHits = exactOut.split("\n").map((name) => name.trim()).filter((name) => name !== "");
|
|
503
|
-
if (exactHits.includes(relativePath)) return relativePath;
|
|
504
|
-
|
|
505
|
-
const parent = dirname(relativePath);
|
|
506
|
-
const leaf = basename(relativePath);
|
|
507
|
-
// Path absence is an empty structured ls-tree result (exit 0), never stderr prose.
|
|
508
|
-
if (parent !== ".") {
|
|
509
|
-
const { stdout: parentOut } = await execFileAsync(
|
|
510
|
-
"git",
|
|
511
|
-
["ls-tree", "--name-only", "HEAD", "--", parent],
|
|
512
|
-
{ cwd: topLevel, encoding: "utf8" },
|
|
513
|
-
);
|
|
514
|
-
const parentHits = parentOut.split("\n").map((name) => name.trim()).filter((name) => name !== "");
|
|
515
|
-
if (!parentHits.includes(parent)) return undefined;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Parent confirmed present (or root): list children. Any failure stays loud infrastructure.
|
|
519
|
-
const { stdout: listing } = parent === "."
|
|
520
|
-
? await execFileAsync("git", ["ls-tree", "--name-only", "HEAD"], {
|
|
521
|
-
cwd: topLevel,
|
|
522
|
-
encoding: "utf8",
|
|
523
|
-
})
|
|
524
|
-
: await execFileAsync("git", ["ls-tree", "--name-only", `HEAD:${parent}`], {
|
|
525
|
-
cwd: topLevel,
|
|
526
|
-
encoding: "utf8",
|
|
527
|
-
});
|
|
528
|
-
const needle = leaf.toLowerCase();
|
|
529
|
-
const hits = listing
|
|
530
|
-
.split("\n")
|
|
531
|
-
.map((name) => name.trim())
|
|
532
|
-
.filter((name) => name !== "" && basename(name).toLowerCase() === needle)
|
|
533
|
-
.map((name) => (parent === "." ? basename(name) : join(parent, basename(name))));
|
|
534
|
-
return hits.length === 1 ? hits[0] : undefined;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* True when inspect-reported path is carried by calling-repo HEAD and the bytes
|
|
539
|
-
* a host reads through that path match the HEAD blob
|
|
540
|
-
* (#521 repo-instructions-are-shared-material).
|
|
541
|
-
*
|
|
542
|
-
* Expected negatives (return false): empty/outside path, HEAD does not carry
|
|
543
|
-
* the path, worktree absent, or worktree bytes ≠ HEAD blob.
|
|
544
|
-
* Infrastructure (throw with cause): git unavailable, unexpected git/repo
|
|
545
|
-
* failure, permission or other IO on realpath/hash.
|
|
546
|
-
*/
|
|
547
|
-
export async function isHeadMatchedProjectInstruction(
|
|
548
|
-
repositoryCwd: string,
|
|
549
|
-
absolutePath: string,
|
|
550
|
-
): Promise<boolean> {
|
|
551
|
-
if (absolutePath === "" || absolutePath.includes("\0")) return false;
|
|
552
|
-
|
|
553
|
-
const { stdout: topLevelOut } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
|
|
554
|
-
cwd: repositoryCwd,
|
|
555
|
-
encoding: "utf8",
|
|
556
|
-
});
|
|
557
|
-
// Prove HEAD is readable before path negatives — corrupt/missing HEAD stays loud.
|
|
558
|
-
await execFileAsync("git", ["rev-parse", "--verify", "HEAD"], {
|
|
559
|
-
cwd: repositoryCwd,
|
|
560
|
-
encoding: "utf8",
|
|
561
|
-
});
|
|
562
|
-
const topLevel = await realpath(topLevelOut.trim());
|
|
563
|
-
|
|
564
|
-
// Keep final leaf identity (do not realpath through a final-component symlink).
|
|
565
|
-
const parent = await realpathIfPresent(dirname(absolutePath));
|
|
566
|
-
if (parent === undefined) return false;
|
|
567
|
-
const leaf = basename(absolutePath);
|
|
568
|
-
if (leaf === "" || leaf === "." || leaf === "..") return false;
|
|
569
|
-
const candidate = join(parent, leaf);
|
|
570
|
-
const relative = pathRelative(topLevel, candidate);
|
|
571
|
-
if (relative === "" || relative.startsWith("..") || isAbsolute(relative) || relative.includes("\0")) {
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const headRel = await resolveHeadTreePath(topLevel, relative);
|
|
576
|
-
if (headRel === undefined) return false;
|
|
577
|
-
const headFile = join(topLevel, headRel);
|
|
578
|
-
|
|
579
|
-
const { stdout: headBlobOut } = await execFileAsync(
|
|
580
|
-
"git",
|
|
581
|
-
["rev-parse", "--verify", `HEAD:${headRel}`],
|
|
582
|
-
{ cwd: topLevel, encoding: "utf8" },
|
|
583
|
-
);
|
|
584
|
-
const headBlob = headBlobOut.trim();
|
|
585
|
-
|
|
586
|
-
// Prefer inspect-reported path bytes (hash-object follows symlink content).
|
|
587
|
-
// Worktree absence is typed FS ENOENT; permission and other IO stay loud.
|
|
588
|
-
// When exact casing is absent, fall back to the unique HEAD-cased path.
|
|
589
|
-
let hashTarget = candidate;
|
|
590
|
-
const candidatePresence = await worktreeFilePresence(candidate);
|
|
591
|
-
if (candidatePresence === "absent") {
|
|
592
|
-
if (candidate === headFile) return false;
|
|
593
|
-
const headPresence = await worktreeFilePresence(headFile);
|
|
594
|
-
if (headPresence === "absent") return false;
|
|
595
|
-
hashTarget = headFile;
|
|
596
|
-
}
|
|
597
|
-
const { stdout } = await execFileAsync("git", ["hash-object", "--", hashTarget], {
|
|
598
|
-
cwd: topLevel,
|
|
599
|
-
encoding: "utf8",
|
|
600
|
-
});
|
|
601
|
-
return headBlob === stdout.trim();
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function inspectItemPath(value: InspectItem): string {
|
|
605
|
-
if (typeof value.source?.path === "string") return value.source.path;
|
|
606
|
-
if (typeof value.path === "string") return value.path;
|
|
607
|
-
return "";
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function isInspectItemActive(value: InspectItem): boolean {
|
|
611
|
-
return value.disabled !== true && value.enabled !== false && value.compatibilityStatus !== "disabled";
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/** Collect active projectInstruction paths for HEAD-blob provenance resolution. */
|
|
615
|
-
export function listActiveProjectInstructionPaths(document: Readonly<Record<string, unknown>>): readonly string[] {
|
|
616
|
-
const items = document.projectInstructions;
|
|
617
|
-
if (!Array.isArray(items)) return [];
|
|
618
|
-
const paths: string[] = [];
|
|
619
|
-
for (const value of items as InspectItem[]) {
|
|
620
|
-
if (!isInspectItemActive(value)) continue;
|
|
621
|
-
const path = inspectItemPath(value);
|
|
622
|
-
if (path !== "") paths.push(path);
|
|
623
|
-
}
|
|
624
|
-
return paths;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/** Classify first-party inspect JSON by provenance; wording and item counts are irrelevant. */
|
|
628
|
-
export function classifyGrokInspection(
|
|
629
|
-
document: Readonly<Record<string, unknown>>,
|
|
630
|
-
packageRoot: string,
|
|
631
|
-
options: GrokInspectionClassificationOptions = {},
|
|
632
|
-
): GrokControlledInspection {
|
|
633
|
-
const privateActive = new Set<string>();
|
|
634
|
-
const akActive = new Set<string>();
|
|
635
|
-
const headMatched = options.headMatchedProjectInstructionPaths ?? new Set<string>();
|
|
636
|
-
const externalCompat = document.externalCompat as { cells?: unknown } | undefined;
|
|
637
|
-
if (Array.isArray(externalCompat?.cells)) {
|
|
638
|
-
for (const cell of externalCompat.cells as Array<{ vendor?: unknown; surface?: unknown; enabled?: unknown }>) {
|
|
639
|
-
if (cell.enabled !== true) continue;
|
|
640
|
-
privateActive.add(`externalCompat:${String(cell.vendor)}:${String(cell.surface)}`);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
for (const section of ["skills", "agents", "plugins", "mcpServers", "hooks", "projectInstructions"] as const) {
|
|
644
|
-
const items = document[section];
|
|
645
|
-
if (!Array.isArray(items)) continue;
|
|
646
|
-
for (const value of items as InspectItem[]) {
|
|
647
|
-
if (!isInspectItemActive(value)) continue;
|
|
648
|
-
const sourceType = value.source?.type;
|
|
649
|
-
const path = inspectItemPath(value);
|
|
650
|
-
const identity = `${section}:${typeof value.name === "string" ? value.name : path}`;
|
|
651
|
-
if (sourceType === "builtin" || sourceType === "bundled") continue;
|
|
652
|
-
if (path === packageRoot || path.startsWith(`${packageRoot}/`)) akActive.add(identity);
|
|
653
|
-
else if (section === "projectInstructions" && headMatched.has(path)) continue;
|
|
654
|
-
else privateActive.add(identity);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
return { privateActive: [...privateActive].sort(), akActive: [...akActive].sort() };
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/** AK Fixer PreToolUse seatbelt files written under controlled GROK_HOME/hooks. */
|
|
661
|
-
export const AK_BASH_SEATBELT_HOOK_FILES = ["ak-bash-seatbelt.json", "ak-bash-seatbelt.mjs"] as const;
|
|
662
|
-
|
|
663
|
-
function isMissingPathError(error: unknown): boolean {
|
|
664
|
-
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/** Refuse symlink roots so copy/rm never follow a redirected controlled home (#594 F4). */
|
|
668
|
-
export async function assertControlledGrokHomeIsRealDirectory(controlledHome: string): Promise<void> {
|
|
669
|
-
let st;
|
|
670
|
-
try {
|
|
671
|
-
st = await lstat(controlledHome);
|
|
672
|
-
} catch (error) {
|
|
673
|
-
if (isMissingPathError(error)) return;
|
|
674
|
-
throw error;
|
|
675
|
-
}
|
|
676
|
-
if (st.isSymbolicLink()) {
|
|
677
|
-
throw new Error(`controlled grok home must not be a symlink: ${controlledHome}`);
|
|
678
|
-
}
|
|
679
|
-
if (!st.isDirectory()) {
|
|
680
|
-
throw new Error(`controlled grok home must be a real directory: ${controlledHome}`);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/** Refuse a symlink credential destination before copy or scrub (#594 F4). */
|
|
685
|
-
export async function assertControlledGrokAuthIsNotSymlink(authPath: string): Promise<void> {
|
|
686
|
-
let st;
|
|
687
|
-
try {
|
|
688
|
-
st = await lstat(authPath);
|
|
689
|
-
} catch (error) {
|
|
690
|
-
if (isMissingPathError(error)) return;
|
|
691
|
-
throw error;
|
|
692
|
-
}
|
|
693
|
-
if (st.isSymbolicLink()) {
|
|
694
|
-
throw new Error(`controlled grok auth must not be a symlink: ${authPath}`);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/** Remove AK seatbelt hook residue while leaving sessions/ intact (#594 F1). */
|
|
699
|
-
export async function scrubAkBashSeatbeltHooks(controlledHome: string): Promise<void> {
|
|
700
|
-
const hooksDir = join(controlledHome, "hooks");
|
|
701
|
-
for (const name of AK_BASH_SEATBELT_HOOK_FILES) {
|
|
702
|
-
await rm(join(hooksDir, name), { force: true });
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
/**
|
|
707
|
-
* Copy only Grok's authentication authority into an otherwise isolated home.
|
|
708
|
-
* Refuses symlink home/auth destinations (no follow). Scrubs crash-window residual
|
|
709
|
-
* auth.json and AK seatbelt hooks before the copy so the next inspect cannot see
|
|
710
|
-
* either residue (#594 F1/F3/F4).
|
|
711
|
-
*/
|
|
712
|
-
export async function prepareControlledGrokHome(sourceHome: string, controlledHome: string): Promise<void> {
|
|
713
|
-
await assertControlledGrokHomeIsRealDirectory(controlledHome);
|
|
714
|
-
await mkdir(controlledHome, { recursive: true, mode: 0o700 });
|
|
715
|
-
await assertControlledGrokHomeIsRealDirectory(controlledHome);
|
|
716
|
-
const authPath = join(controlledHome, "auth.json");
|
|
717
|
-
await assertControlledGrokAuthIsNotSymlink(authPath);
|
|
718
|
-
// Crash-window residue: prior auth.json may still sit in the retained ledger.
|
|
719
|
-
await rm(authPath, { force: true });
|
|
720
|
-
await scrubAkBashSeatbeltHooks(controlledHome);
|
|
721
|
-
await copyFile(join(sourceHome, ".grok", "auth.json"), authPath);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** First-party structured inspection under the exact environment used by ACP. */
|
|
725
|
-
export async function inspectControlledGrok(options: {
|
|
726
|
-
readonly binary: string;
|
|
727
|
-
readonly cwd: string;
|
|
728
|
-
readonly env: NodeJS.ProcessEnv;
|
|
729
|
-
readonly packageRoot: string;
|
|
730
|
-
}): Promise<GrokControlledInspection> {
|
|
731
|
-
const { stdout } = await execFileAsync(options.binary, ["inspect", "--json"], {
|
|
732
|
-
cwd: options.cwd,
|
|
733
|
-
env: options.env,
|
|
734
|
-
encoding: "utf8",
|
|
735
|
-
});
|
|
736
|
-
const document: unknown = JSON.parse(stdout);
|
|
737
|
-
if (typeof document !== "object" || document === null || Array.isArray(document)) {
|
|
738
|
-
throw new Error("Grok structured inspection did not return an object");
|
|
739
|
-
}
|
|
740
|
-
const record = document as Readonly<Record<string, unknown>>;
|
|
741
|
-
const headMatchedProjectInstructionPaths = new Set<string>();
|
|
742
|
-
for (const path of listActiveProjectInstructionPaths(record)) {
|
|
743
|
-
if (path === options.packageRoot || path.startsWith(`${options.packageRoot}/`)) continue;
|
|
744
|
-
if (await isHeadMatchedProjectInstruction(options.cwd, path)) {
|
|
745
|
-
headMatchedProjectInstructionPaths.add(path);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
return classifyGrokInspection(record, options.packageRoot, { headMatchedProjectInstructionPaths });
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** Exact child environment shared by inspect and ACP agent processes. */
|
|
752
|
-
export function controlledGrokChildEnv(base: NodeJS.ProcessEnv, grokHome: string): NodeJS.ProcessEnv {
|
|
399
|
+
/** Child environment shared by ACP agent processes. */
|
|
400
|
+
export function controlledGrokChildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
753
401
|
return {
|
|
754
402
|
...base,
|
|
755
403
|
...PRIVATE_COMPAT_ENV,
|
|
756
|
-
HOME: grokHome,
|
|
757
|
-
GROK_HOME: grokHome,
|
|
758
404
|
GROK_MEMORY: "0",
|
|
759
405
|
GROK_SUBAGENTS: "0",
|
|
760
406
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Closed host discriminators only; unknown previous/live hosts never inject.
|
|
4
4
|
*/
|
|
5
5
|
import { access, readdir } from "node:fs/promises";
|
|
6
|
-
import { join } from "node:path";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
7
|
|
|
8
8
|
import type { RoleTurnHostTransition } from "./host-contracts.ts";
|
|
9
9
|
|
|
@@ -29,42 +29,33 @@ async function listPiNativeRecordPaths(sessionFile: string): Promise<string[]> {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Present sitian records.jsonl paths under sessionParent topology (#717).
|
|
34
|
+
* resolveSitianRecordPathInLedger writes dirname(sessionParent)/<category>/records.jsonl
|
|
35
|
+
* when sessionParent is inside ledger home — never session.jsonl itself.
|
|
36
|
+
*/
|
|
37
|
+
async function listSitianRecordPaths(sessionParent: string): Promise<string[]> {
|
|
38
|
+
const sessionRoot = dirname(sessionParent);
|
|
39
|
+
let entries;
|
|
36
40
|
try {
|
|
37
|
-
|
|
41
|
+
entries = await readdir(sessionRoot, { withFileTypes: true });
|
|
38
42
|
} catch (error) {
|
|
39
43
|
if (isEnoent(error)) return [];
|
|
40
44
|
throw error;
|
|
41
45
|
}
|
|
42
|
-
const
|
|
43
|
-
for (const
|
|
44
|
-
if (!
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
sessionDirs = await readdir(join(grokSessionsDir, cwdEntry.name), { withFileTypes: true });
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (isEnoent(error)) continue;
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
for (const sessEntry of sessionDirs) {
|
|
53
|
-
if (!sessEntry.isDirectory()) continue;
|
|
54
|
-
updatesPaths.push(join(grokSessionsDir, cwdEntry.name, sessEntry.name, "updates.jsonl"));
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
updatesPaths.sort();
|
|
58
|
-
const present: string[] = [];
|
|
59
|
-
for (const updatesFile of updatesPaths) {
|
|
46
|
+
const recordPaths: string[] = [];
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (!entry.isDirectory()) continue;
|
|
49
|
+
const recordFile = join(sessionRoot, entry.name, "records.jsonl");
|
|
60
50
|
try {
|
|
61
|
-
await access(
|
|
62
|
-
|
|
51
|
+
await access(recordFile);
|
|
52
|
+
recordPaths.push(recordFile);
|
|
63
53
|
} catch (error) {
|
|
64
54
|
if (!isEnoent(error)) throw error;
|
|
65
55
|
}
|
|
66
56
|
}
|
|
67
|
-
|
|
57
|
+
recordPaths.sort();
|
|
58
|
+
return recordPaths;
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
/**
|
|
@@ -72,13 +63,13 @@ export async function listGrokNativeRecordPaths(runDirectory: string): Promise<s
|
|
|
72
63
|
* Unknown host names → undefined (no inject). Empty native volume still
|
|
73
64
|
* yields a typed switch (empty path list).
|
|
74
65
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
66
|
+
* Pi previous → Pi session.jsonl path.
|
|
67
|
+
* Grok previous → sitian records on the live run (#717). Grok CLI journals
|
|
68
|
+
* stay in the operator grok home; they are not copied here.
|
|
77
69
|
*/
|
|
78
70
|
export async function projectHostTransitionPriorNative(input: {
|
|
79
71
|
readonly previousHost: string;
|
|
80
72
|
readonly liveHost: string;
|
|
81
|
-
readonly runDirectory: string;
|
|
82
73
|
readonly piSessionFile: string;
|
|
83
74
|
}): Promise<RoleTurnHostTransition | undefined> {
|
|
84
75
|
if (input.previousHost === input.liveHost) return undefined;
|
|
@@ -92,8 +83,8 @@ export async function projectHostTransitionPriorNative(input: {
|
|
|
92
83
|
priorNativePaths: paths,
|
|
93
84
|
};
|
|
94
85
|
}
|
|
95
|
-
// previousHost === "grok-build":
|
|
96
|
-
const paths = await
|
|
86
|
+
// previousHost === "grok-build": sitian path handoff only — do not read bytes.
|
|
87
|
+
const paths = await listSitianRecordPaths(input.piSessionFile);
|
|
97
88
|
return {
|
|
98
89
|
previousHost: "grok-build",
|
|
99
90
|
priorNativePaths: paths,
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
FIXER_BASH_FORBIDDEN_LITERALS,
|
|
6
|
-
fixerBashSeatbeltDenyReason,
|
|
7
|
-
} from "../fixer-bash-seatbelt.ts";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Render the Grok PreToolUse hook script.
|
|
11
|
-
* Literals and deny reason come from the single ADR 0008 authority; this file is
|
|
12
|
-
* transport-only (stdin event → stdout decision JSON for the Grok hook runner).
|
|
13
|
-
*/
|
|
14
|
-
export function renderGrokBashSeatbeltHookScript(): string {
|
|
15
|
-
// Reason templates are baked from the authority so the standalone hook process
|
|
16
|
-
// cannot drift from fixerBashSeatbeltDenyReason without editing this render call.
|
|
17
|
-
const reasons = Object.fromEntries(
|
|
18
|
-
FIXER_BASH_FORBIDDEN_LITERALS.map((literal) => [literal, fixerBashSeatbeltDenyReason(literal)]),
|
|
19
|
-
);
|
|
20
|
-
return `#!/usr/bin/env node
|
|
21
|
-
const LITERALS = ${JSON.stringify([...FIXER_BASH_FORBIDDEN_LITERALS])};
|
|
22
|
-
const REASONS = ${JSON.stringify(reasons)};
|
|
23
|
-
let raw = "";
|
|
24
|
-
process.stdin.setEncoding("utf8");
|
|
25
|
-
process.stdin.on("data", (chunk) => { raw += chunk; });
|
|
26
|
-
process.stdin.on("end", () => {
|
|
27
|
-
let event = {};
|
|
28
|
-
try { event = JSON.parse(raw); } catch { /* fail-open on malformed host payload */ }
|
|
29
|
-
const input = event && typeof event === "object" ? event.toolInput : undefined;
|
|
30
|
-
const command = input && typeof input === "object" && typeof input.command === "string"
|
|
31
|
-
? input.command : undefined;
|
|
32
|
-
const matched = typeof command === "string"
|
|
33
|
-
? LITERALS.find((literal) => command.includes(literal))
|
|
34
|
-
: undefined;
|
|
35
|
-
const decision = matched === undefined
|
|
36
|
-
? { decision: "allow" }
|
|
37
|
-
: { decision: "deny", reason: REASONS[matched] };
|
|
38
|
-
process.stdout.write(JSON.stringify(decision));
|
|
39
|
-
});
|
|
40
|
-
`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Install the AK bash seatbelt into a controlled Grok home (GROK_HOME root).
|
|
45
|
-
* Never writes the operator's private ~/.grok — callers must pass the isolated home.
|
|
46
|
-
*/
|
|
47
|
-
export async function installGrokPreToolUseDeny(controlledHome: string): Promise<void> {
|
|
48
|
-
const hooksDir = join(controlledHome, "hooks");
|
|
49
|
-
await mkdir(hooksDir, { recursive: true });
|
|
50
|
-
const scriptName = "ak-bash-seatbelt.mjs";
|
|
51
|
-
const scriptPath = join(hooksDir, scriptName);
|
|
52
|
-
await writeFile(scriptPath, renderGrokBashSeatbeltHookScript(), { mode: 0o755 });
|
|
53
|
-
await writeFile(join(hooksDir, "ak-bash-seatbelt.json"), `${JSON.stringify({
|
|
54
|
-
hooks: {
|
|
55
|
-
PreToolUse: [
|
|
56
|
-
{
|
|
57
|
-
matcher: "Bash|run_terminal_command",
|
|
58
|
-
hooks: [
|
|
59
|
-
{
|
|
60
|
-
type: "command",
|
|
61
|
-
command: process.execPath + " " + JSON.stringify(scriptPath),
|
|
62
|
-
timeout: 5,
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
},
|
|
66
|
-
],
|
|
67
|
-
},
|
|
68
|
-
}, null, 2)}\n`);
|
|
69
|
-
}
|