@kylecheng3146/agent-ops 0.1.4 → 0.1.6
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 +164 -13
- package/dist/packages/cli/src/args.js +40 -7
- package/dist/packages/cli/src/bin.js +35 -11
- package/dist/packages/cli/src/cli.js +7 -2
- package/dist/packages/cli/src/codex-loop-process.js +70 -0
- package/dist/packages/cli/src/commands/doctor.js +31 -9
- package/dist/packages/cli/src/commands/hook.js +33 -16
- package/dist/packages/cli/src/commands/init.js +9 -5
- package/dist/packages/cli/src/commands/review.js +5 -1
- package/dist/packages/cli/src/commands/uninstall.js +6 -5
- package/dist/packages/cli/src/commands/update.js +16 -5
- package/dist/packages/cli/src/context.js +69 -3
- package/dist/packages/cli/src/hook-process.js +231 -16
- package/dist/packages/cli/src/loop-entry.js +8 -0
- package/dist/packages/cli/src/plan-output.js +9 -4
- package/dist/packages/cli/src/public-plan.js +62 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +36 -15
- package/dist/runtime/src/adapters/claude/config.js +57 -19
- package/dist/runtime/src/adapters/claude/events.js +33 -0
- package/dist/runtime/src/adapters/claude/output.js +8 -7
- package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
- package/dist/runtime/src/adapters/codex/config.js +66 -13
- package/dist/runtime/src/adapters/codex/events.js +33 -0
- package/dist/runtime/src/adapters/codex/output.js +10 -0
- package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
- package/dist/runtime/src/adapters/opencode/config.js +170 -0
- package/dist/runtime/src/adapters/opencode/events.js +49 -0
- package/dist/runtime/src/adapters/opencode/input.js +32 -0
- package/dist/runtime/src/adapters/opencode/output.js +23 -0
- package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
- package/dist/runtime/src/config/explain.js +7 -0
- package/dist/runtime/src/config/hash.js +24 -0
- package/dist/runtime/src/config/merge.js +6 -2
- package/dist/runtime/src/config/migrate.js +19 -4
- package/dist/runtime/src/contracts.js +10 -1
- package/dist/runtime/src/fs/managed-block.js +35 -18
- package/dist/runtime/src/fs/manifest.js +32 -1
- package/dist/runtime/src/fs/transaction.js +14 -2
- package/dist/runtime/src/hooks/advisory.js +16 -0
- package/dist/runtime/src/hooks/codex-loop.js +439 -0
- package/dist/runtime/src/hooks/stop-service.js +70 -0
- package/dist/runtime/src/hooks/stop-verify.js +4 -1
- package/dist/runtime/src/install/codex-loop.js +139 -0
- package/dist/runtime/src/install/doctor.js +182 -14
- package/dist/runtime/src/install/harness.js +294 -35
- package/dist/runtime/src/install/hooks.js +22 -17
- package/dist/runtime/src/install/ownership.js +146 -35
- package/dist/runtime/src/install/plan.js +276 -31
- package/dist/runtime/src/install/probes.js +9 -43
- package/dist/runtime/src/install/profiles.js +12 -3
- package/dist/runtime/src/install/surface-inspection.js +296 -0
- package/dist/runtime/src/install/surfaces.js +11 -0
- package/dist/runtime/src/install/uninstall.js +11 -4
- package/dist/runtime/src/install/update.js +13 -3
- package/dist/runtime/src/logging/local-log.js +25 -0
- package/dist/runtime/src/schema/validate.js +45 -19
- package/dist/runtime/src/task/service.js +3 -3
- package/dist/runtime/src/task/store.js +12 -3
- package/dist/runtime/src/verify/command-executor.js +113 -0
- package/dist/runtime/src/verify/evidence.js +4 -24
- package/dist/runtime/src/verify/service.js +20 -92
- package/dist/runtime/src/verify/spawn.js +6 -1
- package/docs/en/guides/configuration.md +159 -0
- package/docs/en/guides/quickstart.md +9 -0
- package/docs/en/guides/security.md +5 -0
- package/docs/en/spec/README.md +9 -0
- package/docs/en/spec/harness-adapters.md +104 -2
- package/docs/en/spec/maintenance.md +11 -0
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +145 -0
- package/docs/zh-TW/guides/quickstart.md +9 -0
- package/docs/zh-TW/guides/security.md +5 -0
- package/docs/zh-TW/spec/README.md +9 -0
- package/docs/zh-TW/spec/harness-adapters.md +89 -3
- package/docs/zh-TW/spec/maintenance.md +11 -1
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +4 -2
- package/schemas/config.schema.json +56 -2
- package/schemas/manifest.schema.json +19 -3
- package/templates/common/AGENTS.block.md +2 -1
- package/templates/common/CLAUDE.block.md +2 -1
- package/dist/runtime/src/review/claude-runner.js +0 -4
- package/dist/runtime/src/review/codex-runner.js +0 -4
|
@@ -1,21 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { claudeHookOutput } from "../../../../runtime/src/adapters/claude/output.js";
|
|
3
|
-
import { normalizeCodexHookInput } from "../../../../runtime/src/adapters/codex/input.js";
|
|
4
|
-
import { codexHookOutput } from "../../../../runtime/src/adapters/codex/output.js";
|
|
1
|
+
import { runLifecycleAdvisory } from "../../../../runtime/src/hooks/advisory.js";
|
|
5
2
|
import { dispatchHookEvent } from "../../../../runtime/src/hooks/dispatch.js";
|
|
6
|
-
import {
|
|
3
|
+
import { resolveCapabilities } from "../../../../runtime/src/install/profiles.js";
|
|
4
|
+
import { harnessDescriptor } from "../../../../runtime/src/install/harness.js";
|
|
7
5
|
export const HOOK_EVENTS = [
|
|
8
6
|
"SessionStart",
|
|
9
7
|
"PreToolUse",
|
|
10
8
|
"Stop"
|
|
11
9
|
];
|
|
10
|
+
/**
|
|
11
|
+
* Keep adapter normalization at the command boundary so exceptional hook
|
|
12
|
+
* paths use the same native-event interpretation as normal dispatch.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeHookInput(harness, input) {
|
|
15
|
+
try {
|
|
16
|
+
return harnessDescriptor(harness).runtime.normalizeInput(input);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
12
22
|
/**
|
|
13
23
|
* Hooks are advisory infrastructure: every failure path stays fail-open with
|
|
14
24
|
* exit code 0 so a broken toolkit can never wedge the harness.
|
|
15
25
|
*/
|
|
16
26
|
export async function runHookCommand(options) {
|
|
17
27
|
try {
|
|
18
|
-
const { capabilities } =
|
|
28
|
+
const { capabilities } = options.config.profiles.length === 0
|
|
29
|
+
? { capabilities: [] }
|
|
30
|
+
: resolveCapabilities(options.config);
|
|
19
31
|
let input;
|
|
20
32
|
try {
|
|
21
33
|
input = JSON.parse(options.stdin);
|
|
@@ -23,18 +35,23 @@ export async function runHookCommand(options) {
|
|
|
23
35
|
catch {
|
|
24
36
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
25
37
|
}
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
const descriptor = harnessDescriptor(options.harness);
|
|
39
|
+
const normalized = normalizeHookInput(options.harness, input);
|
|
40
|
+
if (normalized === null) {
|
|
41
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
42
|
+
}
|
|
43
|
+
const stopRegistration = descriptor.control.registrations.find(({ capability }) => capability === "optional-stop-verify");
|
|
44
|
+
const stopVerification = options.stopVerification !== undefined &&
|
|
45
|
+
stopRegistration?.support !== "unsupported"
|
|
46
|
+
? options.stopVerification
|
|
47
|
+
: undefined;
|
|
48
|
+
const result = await dispatchHookEvent(normalized, {
|
|
30
49
|
capabilities,
|
|
31
|
-
trusted: options.trusted
|
|
50
|
+
trusted: options.trusted,
|
|
51
|
+
advisory: options.advisory ?? runLifecycleAdvisory,
|
|
52
|
+
...(stopVerification === undefined ? {} : { stopVerification })
|
|
32
53
|
});
|
|
33
|
-
|
|
34
|
-
return claudeHookOutput(options.event, result);
|
|
35
|
-
}
|
|
36
|
-
const codex = codexHookOutput(options.event, result);
|
|
37
|
-
return { exitCode: 0, stdout: codex.stdout, stderr: "" };
|
|
54
|
+
return descriptor.runtime.formatOutput(options.event, result);
|
|
38
55
|
}
|
|
39
56
|
catch {
|
|
40
57
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
@@ -3,6 +3,7 @@ import { createInstallPlan } from "../../../../runtime/src/install/plan.js";
|
|
|
3
3
|
import { applyInstallPlan } from "../../../../runtime/src/install/apply.js";
|
|
4
4
|
import { okEnvelope } from "../output.js";
|
|
5
5
|
import { formatOperationPlan } from "../plan-output.js";
|
|
6
|
+
import { toPublicInstallPlan } from "../public-plan.js";
|
|
6
7
|
export function formatInstallPlan(plan) {
|
|
7
8
|
const hooks = plan.manifest.hooks ?? [];
|
|
8
9
|
return formatOperationPlan({
|
|
@@ -18,7 +19,7 @@ export function formatInstallPlan(plan) {
|
|
|
18
19
|
...hooks.map((hook) => ` - ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
|
|
19
20
|
])
|
|
20
21
|
],
|
|
21
|
-
operations: plan.operations
|
|
22
|
+
operations: toPublicInstallPlan(plan).operations
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
function appliedMessage(plan) {
|
|
@@ -36,7 +37,7 @@ function initError(code, message, plan) {
|
|
|
36
37
|
return {
|
|
37
38
|
code,
|
|
38
39
|
status: "error",
|
|
39
|
-
data: { applied: false, plan, message },
|
|
40
|
+
data: { applied: false, plan: toPublicInstallPlan(plan), message },
|
|
40
41
|
errors: [{ code, message }]
|
|
41
42
|
};
|
|
42
43
|
}
|
|
@@ -59,12 +60,15 @@ export async function runInitCommand(options) {
|
|
|
59
60
|
: { toolkitVersion: options.toolkitVersion }),
|
|
60
61
|
...(options.hookRuntimePath === undefined
|
|
61
62
|
? {}
|
|
62
|
-
: { hookRuntimePath: options.hookRuntimePath })
|
|
63
|
+
: { hookRuntimePath: options.hookRuntimePath }),
|
|
64
|
+
...((options.hookTargets ?? args.hookTargets) === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { hookTargets: options.hookTargets ?? args.hookTargets })
|
|
63
67
|
});
|
|
64
68
|
if (args.dryRun) {
|
|
65
69
|
return okEnvelope("INIT_PLAN_READY", {
|
|
66
70
|
applied: false,
|
|
67
|
-
plan,
|
|
71
|
+
plan: toPublicInstallPlan(plan),
|
|
68
72
|
message: "Installation plan calculated; no files were written.",
|
|
69
73
|
text: formatInstallPlan(plan)
|
|
70
74
|
});
|
|
@@ -80,7 +84,7 @@ export async function runInitCommand(options) {
|
|
|
80
84
|
await applyInstallPlan(options.root, plan);
|
|
81
85
|
return okEnvelope("INIT_APPLIED", {
|
|
82
86
|
applied: true,
|
|
83
|
-
plan,
|
|
87
|
+
plan: toPublicInstallPlan(plan),
|
|
84
88
|
message: appliedMessage(plan)
|
|
85
89
|
});
|
|
86
90
|
}
|
|
@@ -2,8 +2,12 @@ import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
|
|
|
2
2
|
import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
|
|
3
3
|
import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
4
4
|
import { okEnvelope } from "../output.js";
|
|
5
|
+
/**
|
|
6
|
+
* Review runs against one harness. Argument parsing already rejects a
|
|
7
|
+
* multi-harness selection here, so the first entry is the whole selection.
|
|
8
|
+
*/
|
|
5
9
|
function harness(value) {
|
|
6
|
-
return value
|
|
10
|
+
return value?.[0] ?? "codex";
|
|
7
11
|
}
|
|
8
12
|
export async function runReviewCommand(options) {
|
|
9
13
|
const ids = options.args.criteria ?? [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyUninstallPlan, createUninstallPlan } from "../../../../runtime/src/install/uninstall.js";
|
|
2
2
|
import { okEnvelope } from "../output.js";
|
|
3
3
|
import { formatOperationPlan } from "../plan-output.js";
|
|
4
|
+
import { toPublicUninstallPlan } from "../public-plan.js";
|
|
4
5
|
function formatUninstallPlan(plan) {
|
|
5
6
|
return formatOperationPlan({
|
|
6
7
|
title: "Uninstall plan",
|
|
@@ -13,14 +14,14 @@ function formatUninstallPlan(plan) {
|
|
|
13
14
|
`Harness: ${plan.manifest.harness}`
|
|
14
15
|
])
|
|
15
16
|
],
|
|
16
|
-
operations: plan.operations
|
|
17
|
+
operations: toPublicUninstallPlan(plan).operations
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
20
|
function uninstallError(code, message, plan) {
|
|
20
21
|
return {
|
|
21
22
|
code,
|
|
22
23
|
status: "error",
|
|
23
|
-
data: { applied: false, plan, message },
|
|
24
|
+
data: { applied: false, plan: toPublicUninstallPlan(plan), message },
|
|
24
25
|
errors: [{ code, message }]
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -29,14 +30,14 @@ export async function runUninstallCommand(options) {
|
|
|
29
30
|
if (!plan.installed) {
|
|
30
31
|
return okEnvelope("UNINSTALL_NOT_INSTALLED", {
|
|
31
32
|
applied: false,
|
|
32
|
-
plan,
|
|
33
|
+
plan: toPublicUninstallPlan(plan),
|
|
33
34
|
message: "No managed installation exists."
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
if (options.args.dryRun) {
|
|
37
38
|
return okEnvelope("UNINSTALL_PLAN_READY", {
|
|
38
39
|
applied: false,
|
|
39
|
-
plan,
|
|
40
|
+
plan: toPublicUninstallPlan(plan),
|
|
40
41
|
message: "Uninstall plan calculated; no files were changed.",
|
|
41
42
|
text: formatUninstallPlan(plan)
|
|
42
43
|
});
|
|
@@ -51,7 +52,7 @@ export async function runUninstallCommand(options) {
|
|
|
51
52
|
await applyUninstallPlan(options.root, plan);
|
|
52
53
|
return okEnvelope("UNINSTALL_APPLIED", {
|
|
53
54
|
applied: true,
|
|
54
|
-
plan,
|
|
55
|
+
plan: toPublicUninstallPlan(plan),
|
|
55
56
|
message: "Managed installation content was removed."
|
|
56
57
|
});
|
|
57
58
|
}
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
|
|
2
2
|
import { okEnvelope } from "../output.js";
|
|
3
3
|
import { formatOperationPlan } from "../plan-output.js";
|
|
4
|
+
import { toPublicUpdatePlan } from "../public-plan.js";
|
|
4
5
|
export function formatUpdatePlan(plan) {
|
|
5
6
|
return formatOperationPlan({
|
|
6
7
|
title: "Update plan",
|
|
7
8
|
metadata: [
|
|
8
9
|
`Target version: ${plan.targetVersion}`,
|
|
10
|
+
`Harness: ${plan.installation.harness.join(", ")}`,
|
|
9
11
|
`Schema migrations: ${plan.migrationSteps.length === 0
|
|
10
12
|
? "none"
|
|
11
13
|
: plan.migrationSteps
|
|
12
14
|
.map(({ fromVersion, toVersion }) => `${fromVersion} -> ${toVersion}`)
|
|
13
15
|
.join(", ")}`
|
|
14
16
|
],
|
|
15
|
-
operations: plan.installation.operations
|
|
17
|
+
operations: toPublicUpdatePlan(plan).installation.operations
|
|
16
18
|
});
|
|
17
19
|
}
|
|
18
20
|
function updateError(code, message, plan) {
|
|
19
21
|
return {
|
|
20
22
|
code,
|
|
21
23
|
status: "error",
|
|
22
|
-
data: { applied: false, plan, message },
|
|
24
|
+
data: { applied: false, plan: toPublicUpdatePlan(plan), message },
|
|
23
25
|
errors: [{ code, message }]
|
|
24
26
|
};
|
|
25
27
|
}
|
|
@@ -27,20 +29,29 @@ export async function runUpdateCommand(options) {
|
|
|
27
29
|
const plan = await createUpdatePlan({
|
|
28
30
|
root: options.root,
|
|
29
31
|
adapters: options.adapters,
|
|
32
|
+
...(options.args.harness === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { harness: options.args.harness }),
|
|
30
35
|
...(options.registry === undefined
|
|
31
36
|
? {}
|
|
32
37
|
: { registry: options.registry }),
|
|
33
38
|
...(options.targetVersion === undefined
|
|
34
39
|
? {}
|
|
35
40
|
: { targetVersion: options.targetVersion }),
|
|
41
|
+
...(options.toolkitVersion === undefined
|
|
42
|
+
? {}
|
|
43
|
+
: { toolkitVersion: options.toolkitVersion }),
|
|
36
44
|
...(options.hookRuntimePath === undefined
|
|
37
45
|
? {}
|
|
38
|
-
: { hookRuntimePath: options.hookRuntimePath })
|
|
46
|
+
: { hookRuntimePath: options.hookRuntimePath }),
|
|
47
|
+
...((options.hookTargets ?? options.args.hookTargets) === undefined
|
|
48
|
+
? {}
|
|
49
|
+
: { hookTargets: options.hookTargets ?? options.args.hookTargets })
|
|
39
50
|
});
|
|
40
51
|
if (options.args.dryRun) {
|
|
41
52
|
return okEnvelope("UPDATE_PLAN_READY", {
|
|
42
53
|
applied: false,
|
|
43
|
-
plan,
|
|
54
|
+
plan: toPublicUpdatePlan(plan),
|
|
44
55
|
message: "Update plan calculated; no files were changed.",
|
|
45
56
|
text: formatUpdatePlan(plan)
|
|
46
57
|
});
|
|
@@ -55,7 +66,7 @@ export async function runUpdateCommand(options) {
|
|
|
55
66
|
await applyUpdatePlan(options.root, plan);
|
|
56
67
|
return okEnvelope("UPDATE_APPLIED", {
|
|
57
68
|
applied: true,
|
|
58
|
-
plan,
|
|
69
|
+
plan: toPublicUpdatePlan(plan),
|
|
59
70
|
message: `Managed installation updated to ${plan.targetVersion}.`
|
|
60
71
|
});
|
|
61
72
|
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
5
|
+
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
4
6
|
import { loadConfigFile } from "../../../runtime/src/config/load.js";
|
|
5
7
|
import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
|
|
6
|
-
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
7
8
|
import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
8
9
|
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
9
10
|
import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
10
11
|
export const DEFAULT_CONFIG = {
|
|
11
|
-
schemaVersion:
|
|
12
|
+
schemaVersion: 2,
|
|
12
13
|
profiles: [],
|
|
13
14
|
verification: { commands: [] },
|
|
15
|
+
features: {
|
|
16
|
+
stopVerification: {
|
|
17
|
+
enabled: false
|
|
18
|
+
}
|
|
19
|
+
},
|
|
14
20
|
pathMappings: [],
|
|
15
21
|
securityExceptions: []
|
|
16
22
|
};
|
|
@@ -73,6 +79,66 @@ export async function loadEffectiveConfig(root, scope) {
|
|
|
73
79
|
}
|
|
74
80
|
return mergeConfigLayers(layers);
|
|
75
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Classifies only configuration that can participate in a project hook. The
|
|
84
|
+
* regular command loader intentionally keeps its throwing contract so CLI
|
|
85
|
+
* commands continue to surface configuration errors to the human.
|
|
86
|
+
*/
|
|
87
|
+
export async function loadProjectHookConfig(root) {
|
|
88
|
+
const home = process.env.AGENT_OPS_HOME ?? homedir();
|
|
89
|
+
const userPath = join(home, ".agent-ops", "config.json");
|
|
90
|
+
const projectPath = join(root, ".agent-ops", "config.json");
|
|
91
|
+
const layers = [defaultConfigLayer()];
|
|
92
|
+
let project;
|
|
93
|
+
try {
|
|
94
|
+
project = await loadOptionalConfig(projectPath);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { kind: "invalid", path: projectPath };
|
|
98
|
+
}
|
|
99
|
+
if (project === null && projectPath === userPath) {
|
|
100
|
+
return { kind: "absent", config: DEFAULT_CONFIG };
|
|
101
|
+
}
|
|
102
|
+
if (projectPath !== userPath) {
|
|
103
|
+
try {
|
|
104
|
+
const user = await loadOptionalConfig(userPath);
|
|
105
|
+
if (user !== null) {
|
|
106
|
+
layers.push({
|
|
107
|
+
source: "user",
|
|
108
|
+
sourcePath: user.sourcePath,
|
|
109
|
+
config: user.config
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A user-level error cannot make a project with no own config deny a
|
|
115
|
+
// tool call, but it remains a classified failure for an installed
|
|
116
|
+
// project that does own a config.
|
|
117
|
+
return project === null
|
|
118
|
+
? { kind: "absent", config: DEFAULT_CONFIG }
|
|
119
|
+
: { kind: "invalid", path: userPath };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (project === null) {
|
|
123
|
+
try {
|
|
124
|
+
return { kind: "absent", config: mergeConfigLayers(layers).config };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { kind: "absent", config: DEFAULT_CONFIG };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
layers.push({
|
|
131
|
+
source: "project",
|
|
132
|
+
sourcePath: project.sourcePath,
|
|
133
|
+
config: project.config
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
return { kind: "loaded", config: mergeConfigLayers(layers).config };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { kind: "invalid", path: projectPath };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
76
142
|
export function repositoryRemoteUrl(root) {
|
|
77
143
|
try {
|
|
78
144
|
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
@@ -91,7 +157,7 @@ export async function repositoryTrust(root, config, cliVersion) {
|
|
|
91
157
|
const binding = await calculateTrustBinding({
|
|
92
158
|
repositoryPath: root,
|
|
93
159
|
remoteUrl: repositoryRemoteUrl(root),
|
|
94
|
-
configHash:
|
|
160
|
+
configHash: calculateConfigHash(config),
|
|
95
161
|
runtimeHash: sha256(cliVersion)
|
|
96
162
|
});
|
|
97
163
|
return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
7
|
+
import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../../../runtime/src/fs/manifest.js";
|
|
8
|
+
import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
|
|
9
|
+
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
10
|
+
import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
|
|
11
|
+
import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
|
|
12
|
+
import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
|
|
13
|
+
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
14
|
+
import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
|
|
15
|
+
import { loadProjectHookConfig, repositoryTrust } from "./context.js";
|
|
16
|
+
const HARNESSES = new Set(HARNESS_IDS);
|
|
4
17
|
const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
|
|
18
|
+
const MAX_HOOK_MANIFEST_BYTES = 1024 * 1024;
|
|
19
|
+
const execFile = promisify(execFileCallback);
|
|
5
20
|
async function readStdin(stream) {
|
|
6
21
|
const chunks = [];
|
|
7
22
|
let total = 0;
|
|
@@ -17,35 +32,235 @@ async function readStdin(stream) {
|
|
|
17
32
|
}
|
|
18
33
|
return Buffer.concat(chunks, total).toString("utf8");
|
|
19
34
|
}
|
|
35
|
+
function parseInput(source) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(source);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function writeHookOutput(io, output) {
|
|
44
|
+
if (output.stdout.length > 0) {
|
|
45
|
+
io.writeStdout(output.stdout);
|
|
46
|
+
}
|
|
47
|
+
if (output.stderr.length > 0) {
|
|
48
|
+
io.writeStderr(output.stderr);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function failOpenOutput(harness, event) {
|
|
52
|
+
return harnessDescriptor(harness).runtime.formatOutput(event, {
|
|
53
|
+
action: "continue",
|
|
54
|
+
status: "PASS",
|
|
55
|
+
code: "HOOK_FAIL_OPEN"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function readInstalledManifestForHarness(root, harness) {
|
|
59
|
+
try {
|
|
60
|
+
const path = await resolveContainedPath(root, PROJECT_MANIFEST_PATH);
|
|
61
|
+
const before = await lstat(path, { bigint: true });
|
|
62
|
+
if (!before.isFile() ||
|
|
63
|
+
before.size > BigInt(MAX_HOOK_MANIFEST_BYTES)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
67
|
+
try {
|
|
68
|
+
const opened = await handle.stat({ bigint: true });
|
|
69
|
+
const resolvedAgain = await resolveContainedPath(root, PROJECT_MANIFEST_PATH);
|
|
70
|
+
const after = await lstat(resolvedAgain, { bigint: true });
|
|
71
|
+
if (!opened.isFile() ||
|
|
72
|
+
opened.size > BigInt(MAX_HOOK_MANIFEST_BYTES) ||
|
|
73
|
+
after.dev !== before.dev ||
|
|
74
|
+
after.ino !== before.ino) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
while (totalBytes <= MAX_HOOK_MANIFEST_BYTES) {
|
|
80
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HOOK_MANIFEST_BYTES + 1 - totalBytes));
|
|
81
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
82
|
+
if (bytesRead === 0) {
|
|
83
|
+
return parseInstallManifest(Buffer.concat(chunks, totalBytes).toString("utf8")).harness.includes(harness);
|
|
84
|
+
}
|
|
85
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
86
|
+
totalBytes += bytesRead;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function hookConfigOutcome(root, loadConfig) {
|
|
99
|
+
if (loadConfig === undefined) {
|
|
100
|
+
return await loadProjectHookConfig(root);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return { kind: "loaded", config: await loadConfig(root) };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return {
|
|
107
|
+
kind: "invalid",
|
|
108
|
+
path: join(root, ".agent-ops", "config.json")
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function invalidConfigOutput(options) {
|
|
113
|
+
const descriptor = harnessDescriptor(options.harness);
|
|
114
|
+
const normalized = normalizeHookInput(options.harness, options.input);
|
|
115
|
+
if (normalized === null) {
|
|
116
|
+
return failOpenOutput(options.harness, options.event);
|
|
117
|
+
}
|
|
118
|
+
if (options.event !== "PreToolUse" ||
|
|
119
|
+
normalized.event !== "command" ||
|
|
120
|
+
!(await readInstalledManifestForHarness(options.root, options.harness))) {
|
|
121
|
+
return failOpenOutput(options.harness, options.event);
|
|
122
|
+
}
|
|
123
|
+
const registration = descriptor.control.registrations.find(({ nativeEvent, normalizedEvent }) => nativeEvent === options.event && normalizedEvent === normalized.event);
|
|
124
|
+
if (registration === undefined || registration.runtimeFailure === "fail-open") {
|
|
125
|
+
return failOpenOutput(options.harness, options.event);
|
|
126
|
+
}
|
|
127
|
+
if (options.harness === "opencode" &&
|
|
128
|
+
registration.runtimeFailure === "fail-closed") {
|
|
129
|
+
// The generated OpenCode plugin already turns an unavailable runtime into
|
|
130
|
+
// its documented blocking error. Keep that contract in the shim.
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const remedy = `Fix ${redactSecrets(options.configPath)}, or set AGENT_OPS_DISABLE=1 in your shell to temporarily disable agent-ops.`;
|
|
134
|
+
return descriptor.runtime.formatRuntimeFailure(options.event, registration.capability, remedy);
|
|
135
|
+
}
|
|
136
|
+
function defaultGitRunner(root) {
|
|
137
|
+
return {
|
|
138
|
+
run: async (args) => {
|
|
139
|
+
try {
|
|
140
|
+
const result = await execFile("git", [...args], {
|
|
141
|
+
cwd: root,
|
|
142
|
+
encoding: "buffer"
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
exitCode: 0,
|
|
146
|
+
stdout: result.stdout
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
const failure = error;
|
|
151
|
+
return {
|
|
152
|
+
exitCode: failure.status ?? 1,
|
|
153
|
+
stdout: failure.stdout ?? new Uint8Array()
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function stopVerificationOptions(input) {
|
|
160
|
+
const nativeRecursion = input.harness === "claude" &&
|
|
161
|
+
claudeStopRecursionMarker(input.rawInput);
|
|
162
|
+
const recursionActive = nativeRecursion || process.env[STOP_VERIFICATION_ENV] === "1";
|
|
163
|
+
const configHash = calculateConfigHash(input.config);
|
|
164
|
+
return {
|
|
165
|
+
confirmedConfig: true,
|
|
166
|
+
trusted: input.trusted,
|
|
167
|
+
scopeMapped: true,
|
|
168
|
+
recursionMarker: recursionActive,
|
|
169
|
+
configHash,
|
|
170
|
+
verify: async () => await new StopVerificationService({
|
|
171
|
+
root: input.root,
|
|
172
|
+
config: input.config,
|
|
173
|
+
trusted: input.trusted,
|
|
174
|
+
gitRunner: input.gitRunner,
|
|
175
|
+
processRunner: input.processRunner,
|
|
176
|
+
recursionActive,
|
|
177
|
+
configHash
|
|
178
|
+
}).verify()
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
182
|
+
if (event !== "Stop" || !config.features.stopVerification.enabled) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
let normalized;
|
|
186
|
+
try {
|
|
187
|
+
normalized = harnessDescriptor(harness).runtime.normalizeInput(rawInput);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (normalized.event !== "stop") {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
|
|
196
|
+
}
|
|
20
197
|
/**
|
|
21
198
|
* Runs one hook invocation. Always resolves to exit code 0: a hook that
|
|
22
199
|
* cannot answer must never block the harness it advises.
|
|
23
200
|
*/
|
|
24
|
-
export async function runHookProcess(argv, io, cliVersion) {
|
|
201
|
+
export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
25
202
|
const [harness, event] = argv;
|
|
26
203
|
if (harness === undefined ||
|
|
27
204
|
!HARNESSES.has(harness) ||
|
|
28
205
|
event === undefined ||
|
|
29
206
|
!HOOK_EVENTS.includes(event)) {
|
|
30
|
-
io.writeStderr(
|
|
207
|
+
io.writeStderr(`Usage: agent-ops hook <${HARNESS_IDS.join("|")}> <SessionStart|PreToolUse|Stop>\n`);
|
|
31
208
|
return 0;
|
|
32
209
|
}
|
|
33
210
|
try {
|
|
34
|
-
const root = process.cwd();
|
|
35
|
-
const
|
|
211
|
+
const root = dependencies.root ?? process.cwd();
|
|
212
|
+
const harnessId = harness;
|
|
213
|
+
const hookEvent = event;
|
|
214
|
+
if (process.env.AGENT_OPS_DISABLE === "1") {
|
|
215
|
+
writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const rawInput = await readStdin(io.stdin);
|
|
219
|
+
const parsedInput = parseInput(rawInput);
|
|
220
|
+
const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
221
|
+
if (configOutcome.kind === "invalid") {
|
|
222
|
+
const output = await invalidConfigOutput({
|
|
223
|
+
root,
|
|
224
|
+
harness: harnessId,
|
|
225
|
+
event: hookEvent,
|
|
226
|
+
input: parsedInput,
|
|
227
|
+
configPath: configOutcome.path
|
|
228
|
+
});
|
|
229
|
+
if (output !== undefined) {
|
|
230
|
+
writeHookOutput(io, output);
|
|
231
|
+
}
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
const config = configOutcome.config;
|
|
235
|
+
const trustStatus = dependencies.trust === undefined
|
|
236
|
+
? await repositoryTrust(root, config, cliVersion)
|
|
237
|
+
: await dependencies.trust(root, config, cliVersion);
|
|
238
|
+
const trusted = trustStatus === "TRUSTED";
|
|
239
|
+
const gitRunner = dependencies.gitRunner ?? defaultGitRunner(root);
|
|
240
|
+
const processRunner = dependencies.processRunner ?? new NodeVerificationProcessRunner();
|
|
241
|
+
const stopVerification = shouldBuildStopVerification(harnessId, event, config, parsedInput)
|
|
242
|
+
? stopVerificationOptions({
|
|
243
|
+
harness: harness,
|
|
244
|
+
rawInput: parsedInput,
|
|
245
|
+
root,
|
|
246
|
+
config,
|
|
247
|
+
trusted,
|
|
248
|
+
gitRunner,
|
|
249
|
+
processRunner
|
|
250
|
+
})
|
|
251
|
+
: undefined;
|
|
36
252
|
const output = await runHookCommand({
|
|
37
253
|
harness: harness,
|
|
38
|
-
event:
|
|
39
|
-
stdin:
|
|
254
|
+
event: hookEvent,
|
|
255
|
+
stdin: rawInput,
|
|
40
256
|
config,
|
|
41
|
-
trusted
|
|
257
|
+
trusted,
|
|
258
|
+
...(dependencies.advisory === undefined
|
|
259
|
+
? {}
|
|
260
|
+
: { advisory: dependencies.advisory }),
|
|
261
|
+
...(stopVerification === undefined ? {} : { stopVerification })
|
|
42
262
|
});
|
|
43
|
-
|
|
44
|
-
io.writeStdout(output.stdout);
|
|
45
|
-
}
|
|
46
|
-
if (output.stderr.length > 0) {
|
|
47
|
-
io.writeStderr(output.stderr);
|
|
48
|
-
}
|
|
263
|
+
writeHookOutput(io, output);
|
|
49
264
|
}
|
|
50
265
|
catch {
|
|
51
266
|
// ponytail: fail-open by design; hook failures stay invisible to the harness.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point used only by the generated project-local loop launchers.
|
|
3
|
+
import { runLoopProcess } from "./codex-loop-process.js";
|
|
4
|
+
process.exitCode = await runLoopProcess(process.argv.slice(2), {
|
|
5
|
+
stdin: process.stdin,
|
|
6
|
+
writeStdout: (value) => process.stdout.write(value),
|
|
7
|
+
writeStderr: (value) => process.stderr.write(value)
|
|
8
|
+
});
|
|
@@ -18,10 +18,15 @@ export function formatOperationPlan(options) {
|
|
|
18
18
|
for (const operation of options.operations) {
|
|
19
19
|
lines.push(`- ${operation.kind} ${escapeTerminalText(operation.path)}`, ` expected: ${operation.expectedHash ?? "<absent>"}`);
|
|
20
20
|
if (operation.kind === "write") {
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
if ("contentHash" in operation) {
|
|
22
|
+
lines.push(` content: <opaque ${escapeTerminalText(operation.contentHash)}>`, ` summary: ${escapeTerminalText(operation.summary)}`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
lines.push(" content:", ...operation.content
|
|
26
|
+
.replace(/\n$/u, "")
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => ` ${escapeTerminalText(line)}`));
|
|
29
|
+
}
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
return `${lines.join("\n")}\n`;
|