@kylecheng3146/agent-ops 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/SECURITY.md +22 -0
- package/dist/packages/cli/src/args.js +322 -0
- package/dist/packages/cli/src/bin.js +290 -0
- package/dist/packages/cli/src/cli.js +80 -0
- package/dist/packages/cli/src/commands/config.js +8 -0
- package/dist/packages/cli/src/commands/doctor.js +32 -0
- package/dist/packages/cli/src/commands/index.js +16 -0
- package/dist/packages/cli/src/commands/init.js +65 -0
- package/dist/packages/cli/src/commands/review.js +71 -0
- package/dist/packages/cli/src/commands/task.js +141 -0
- package/dist/packages/cli/src/commands/trust.js +48 -0
- package/dist/packages/cli/src/commands/uninstall.js +58 -0
- package/dist/packages/cli/src/commands/update.js +58 -0
- package/dist/packages/cli/src/commands/verify.js +108 -0
- package/dist/packages/cli/src/output.js +53 -0
- package/dist/packages/cli/src/plan-output.js +28 -0
- package/dist/packages/cli/src/wizard.js +75 -0
- package/dist/runtime/src/adapters/claude/config.js +109 -0
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +38 -0
- package/dist/runtime/src/adapters/claude/output.js +30 -0
- package/dist/runtime/src/adapters/codex/config.js +93 -0
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/codex/input.js +41 -0
- package/dist/runtime/src/adapters/codex/output.js +24 -0
- package/dist/runtime/src/config/explain.js +34 -0
- package/dist/runtime/src/config/load.js +25 -0
- package/dist/runtime/src/config/merge.js +170 -0
- package/dist/runtime/src/config/migrate.js +61 -0
- package/dist/runtime/src/contracts.js +1 -0
- package/dist/runtime/src/discovery/go.js +145 -0
- package/dist/runtime/src/discovery/index.js +40 -0
- package/dist/runtime/src/discovery/make.js +162 -0
- package/dist/runtime/src/discovery/node.js +175 -0
- package/dist/runtime/src/discovery/python.js +163 -0
- package/dist/runtime/src/discovery/rust.js +159 -0
- package/dist/runtime/src/discovery/types.js +1 -0
- package/dist/runtime/src/fs/hash.js +19 -0
- package/dist/runtime/src/fs/managed-block.js +90 -0
- package/dist/runtime/src/fs/manifest.js +24 -0
- package/dist/runtime/src/fs/mutation-worker.js +185 -0
- package/dist/runtime/src/fs/paths.js +96 -0
- package/dist/runtime/src/fs/transaction.js +498 -0
- package/dist/runtime/src/guardrails/destructive.js +207 -0
- package/dist/runtime/src/guardrails/evaluate.js +9 -0
- package/dist/runtime/src/guardrails/exceptions.js +49 -0
- package/dist/runtime/src/guardrails/secrets.js +97 -0
- package/dist/runtime/src/guardrails/types.js +9 -0
- package/dist/runtime/src/hooks/dispatch.js +78 -0
- package/dist/runtime/src/hooks/events.js +1 -0
- package/dist/runtime/src/hooks/hook-entry.js +19 -0
- package/dist/runtime/src/hooks/normalize.js +59 -0
- package/dist/runtime/src/hooks/output.js +12 -0
- package/dist/runtime/src/hooks/shell.js +138 -0
- package/dist/runtime/src/hooks/stop-verify.js +70 -0
- package/dist/runtime/src/install/apply.js +70 -0
- package/dist/runtime/src/install/doctor.js +196 -0
- package/dist/runtime/src/install/harness.js +87 -0
- package/dist/runtime/src/install/ownership.js +84 -0
- package/dist/runtime/src/install/plan.js +257 -0
- package/dist/runtime/src/install/profiles.js +28 -0
- package/dist/runtime/src/install/types.js +1 -0
- package/dist/runtime/src/install/uninstall.js +206 -0
- package/dist/runtime/src/install/update.js +123 -0
- package/dist/runtime/src/logging/local-log.js +158 -0
- package/dist/runtime/src/registry/npm.js +141 -0
- package/dist/runtime/src/review/claude-runner.js +4 -0
- package/dist/runtime/src/review/codex-runner.js +4 -0
- package/dist/runtime/src/review/packet.js +10 -0
- package/dist/runtime/src/review/result.js +24 -0
- package/dist/runtime/src/review/roles.js +3 -0
- package/dist/runtime/src/review/runner.js +45 -0
- package/dist/runtime/src/schema/validate.js +584 -0
- package/dist/runtime/src/security/permissions.js +654 -0
- package/dist/runtime/src/security/redact.js +41 -0
- package/dist/runtime/src/security/trust.js +209 -0
- package/dist/runtime/src/task/render.js +43 -0
- package/dist/runtime/src/task/service.js +235 -0
- package/dist/runtime/src/task/store.js +265 -0
- package/dist/runtime/src/verify/change-surface.js +86 -0
- package/dist/runtime/src/verify/evidence.js +89 -0
- package/dist/runtime/src/verify/fingerprint.js +67 -0
- package/dist/runtime/src/verify/scope.js +69 -0
- package/dist/runtime/src/verify/service.js +217 -0
- package/dist/runtime/src/verify/spawn.js +326 -0
- package/dist/runtime/src/verify/test-count.js +148 -0
- package/docs/en/spec/README.md +13 -0
- package/docs/en/spec/acceptance-and-evidence.md +21 -0
- package/docs/en/spec/delegation.md +21 -0
- package/docs/en/spec/guardrails.md +21 -0
- package/docs/en/spec/harness-adapters.md +21 -0
- package/docs/en/spec/judgment.md +21 -0
- package/docs/en/spec/loop-engineering.md +23 -0
- package/docs/en/spec/maintenance.md +21 -0
- package/docs/en/spec/review.md +21 -0
- package/docs/en/spec/troubleshooting.md +21 -0
- package/docs/zh-TW/spec/README.md +13 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +23 -0
- package/docs/zh-TW/spec/delegation.md +23 -0
- package/docs/zh-TW/spec/guardrails.md +23 -0
- package/docs/zh-TW/spec/harness-adapters.md +23 -0
- package/docs/zh-TW/spec/judgment.md +23 -0
- package/docs/zh-TW/spec/loop-engineering.md +23 -0
- package/docs/zh-TW/spec/maintenance.md +23 -0
- package/docs/zh-TW/spec/review.md +23 -0
- package/docs/zh-TW/spec/troubleshooting.md +23 -0
- package/package.json +41 -0
- package/schemas/config.schema.json +231 -0
- package/schemas/evidence.schema.json +115 -0
- package/schemas/manifest.schema.json +116 -0
- package/schemas/task.schema.json +59 -0
- package/templates/common/AGENTS.block.md +3 -0
- package/templates/common/CLAUDE.block.md +3 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
|
+
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
export function claudeStopRecursionMarker(input) {
|
|
7
|
+
return (isRecord(input) &&
|
|
8
|
+
input.hook_event_name === "Stop" &&
|
|
9
|
+
input.stop_hook_active === true);
|
|
10
|
+
}
|
|
11
|
+
export function normalizeClaudeHookInput(input) {
|
|
12
|
+
if (!isRecord(input)) {
|
|
13
|
+
return normalizeHookEvent(input);
|
|
14
|
+
}
|
|
15
|
+
const projectRoot = input.cwd;
|
|
16
|
+
if (input.hook_event_name === "SessionStart") {
|
|
17
|
+
return normalizeHookEvent({
|
|
18
|
+
event: "session-start",
|
|
19
|
+
projectRoot
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (input.hook_event_name === "Stop") {
|
|
23
|
+
return normalizeHookEvent({
|
|
24
|
+
event: "stop",
|
|
25
|
+
projectRoot
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (input.hook_event_name === "PreToolUse" &&
|
|
29
|
+
input.tool_name === "Bash" &&
|
|
30
|
+
isRecord(input.tool_input) &&
|
|
31
|
+
typeof input.tool_input.command === "string") {
|
|
32
|
+
return normalizeShellHookEvent(input.tool_input.command, projectRoot);
|
|
33
|
+
}
|
|
34
|
+
return normalizeHookEvent({
|
|
35
|
+
event: "unsupported",
|
|
36
|
+
projectRoot
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function json(value) {
|
|
2
|
+
return {
|
|
3
|
+
exitCode: 0,
|
|
4
|
+
stdout: JSON.stringify(value),
|
|
5
|
+
stderr: ""
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function claudeHookOutput(event, result) {
|
|
9
|
+
if (result.action === "continue" && result.status === "PASS") {
|
|
10
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
11
|
+
}
|
|
12
|
+
if (event === "PreToolUse" && result.action === "block") {
|
|
13
|
+
return json({
|
|
14
|
+
hookSpecificOutput: {
|
|
15
|
+
hookEventName: "PreToolUse",
|
|
16
|
+
permissionDecision: "deny",
|
|
17
|
+
permissionDecisionReason: result.code
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (event === "Stop" && result.action === "block") {
|
|
22
|
+
return json({
|
|
23
|
+
decision: "block",
|
|
24
|
+
reason: result.code
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return json({
|
|
28
|
+
systemMessage: `agent-ops: ${result.code}`
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AgentOpsError } from "../../fs/paths.js";
|
|
2
|
+
const COMMAND_PREFIX = "agent-ops hook codex ";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function commandHook(event) {
|
|
7
|
+
const command = `${COMMAND_PREFIX}${event}`;
|
|
8
|
+
return {
|
|
9
|
+
type: "command",
|
|
10
|
+
command,
|
|
11
|
+
commandWindows: command,
|
|
12
|
+
timeout: 30,
|
|
13
|
+
statusMessage: `Running agent-ops ${event}`
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function matcherGroup(event) {
|
|
17
|
+
return {
|
|
18
|
+
...(event === "PreToolUse" ? { matcher: "^Bash$" } : {}),
|
|
19
|
+
hooks: [commandHook(event)]
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function codexHookTarget(scope) {
|
|
23
|
+
return {
|
|
24
|
+
path: ".codex/hooks.json",
|
|
25
|
+
representation: "json",
|
|
26
|
+
requiresProjectTrust: scope === "project"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function buildCodexHookConfig(capabilities) {
|
|
30
|
+
const hooks = {};
|
|
31
|
+
if (capabilities.includes("lifecycle-summary")) {
|
|
32
|
+
hooks.SessionStart = [matcherGroup("SessionStart")];
|
|
33
|
+
}
|
|
34
|
+
if (capabilities.includes("command-policy")) {
|
|
35
|
+
hooks.PreToolUse = [matcherGroup("PreToolUse")];
|
|
36
|
+
}
|
|
37
|
+
if (capabilities.includes("optional-stop-verify")) {
|
|
38
|
+
hooks.Stop = [matcherGroup("Stop")];
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
description: "Portable agent-ops lifecycle hooks.",
|
|
42
|
+
hooks
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function isOwnedHandler(hook) {
|
|
46
|
+
return (isRecord(hook) &&
|
|
47
|
+
typeof hook.command === "string" &&
|
|
48
|
+
hook.command.startsWith(COMMAND_PREFIX));
|
|
49
|
+
}
|
|
50
|
+
function withoutOwnedHandlers(value) {
|
|
51
|
+
if (!isRecord(value) || !Array.isArray(value.hooks)) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
const hooks = value.hooks.filter((hook) => !isOwnedHandler(hook));
|
|
55
|
+
return hooks.length === 0 ? null : { ...value, hooks };
|
|
56
|
+
}
|
|
57
|
+
function hookRecord(value) {
|
|
58
|
+
if (!isRecord(value)) {
|
|
59
|
+
throw new AgentOpsError("CODEX_HOOK_CONFIG_INVALID", "Codex hook configuration must be a JSON object.");
|
|
60
|
+
}
|
|
61
|
+
if (value.hooks === undefined) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(value.hooks) ||
|
|
65
|
+
Object.values(value.hooks).some((groups) => !Array.isArray(groups))) {
|
|
66
|
+
throw new AgentOpsError("CODEX_HOOK_CONFIG_INVALID", "Codex hook groups must be arrays.");
|
|
67
|
+
}
|
|
68
|
+
return value.hooks;
|
|
69
|
+
}
|
|
70
|
+
export function mergeCodexHookConfig(existing, managed) {
|
|
71
|
+
if (!isRecord(existing)) {
|
|
72
|
+
throw new AgentOpsError("CODEX_HOOK_CONFIG_INVALID", "Codex hook configuration must be a JSON object.");
|
|
73
|
+
}
|
|
74
|
+
const existingHooks = hookRecord(existing);
|
|
75
|
+
const hooks = {};
|
|
76
|
+
const eventNames = new Set([
|
|
77
|
+
...Object.keys(existingHooks),
|
|
78
|
+
...Object.keys(managed.hooks)
|
|
79
|
+
]);
|
|
80
|
+
for (const eventName of eventNames) {
|
|
81
|
+
const preserved = (existingHooks[eventName] ?? [])
|
|
82
|
+
.map(withoutOwnedHandlers)
|
|
83
|
+
.filter((group) => group !== null);
|
|
84
|
+
const additions = managed.hooks[eventName] ?? [];
|
|
85
|
+
if (preserved.length > 0 || additions.length > 0) {
|
|
86
|
+
hooks[eventName] = [...preserved, ...additions];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
...existing,
|
|
91
|
+
hooks
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
|
+
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function shellCommand(input) {
|
|
7
|
+
if (!isRecord(input.tool_input)) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const candidate = input.tool_input.command ?? input.tool_input.cmd;
|
|
11
|
+
return typeof candidate === "string" ? candidate : null;
|
|
12
|
+
}
|
|
13
|
+
export function normalizeCodexHookInput(input) {
|
|
14
|
+
if (!isRecord(input)) {
|
|
15
|
+
return normalizeHookEvent(input);
|
|
16
|
+
}
|
|
17
|
+
const projectRoot = input.cwd;
|
|
18
|
+
if (input.hook_event_name === "SessionStart") {
|
|
19
|
+
return normalizeHookEvent({
|
|
20
|
+
event: "session-start",
|
|
21
|
+
projectRoot
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (input.hook_event_name === "Stop") {
|
|
25
|
+
return normalizeHookEvent({
|
|
26
|
+
event: "stop",
|
|
27
|
+
projectRoot
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (input.hook_event_name === "PreToolUse" &&
|
|
31
|
+
input.tool_name === "Bash") {
|
|
32
|
+
const rawCommand = shellCommand(input);
|
|
33
|
+
if (rawCommand !== null) {
|
|
34
|
+
return normalizeShellHookEvent(rawCommand, projectRoot);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return normalizeHookEvent({
|
|
38
|
+
event: "unsupported",
|
|
39
|
+
projectRoot
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const CODEX_NON_ZERO_EXIT_BEHAVIOR = "UNKNOWN";
|
|
2
|
+
export const CODEX_PRE_TOOL_BLOCKING = "UNKNOWN";
|
|
3
|
+
export function codexHookOutput(event, result) {
|
|
4
|
+
if (result.action === "continue" && result.status === "PASS") {
|
|
5
|
+
return { exitCode: 0, stdout: "" };
|
|
6
|
+
}
|
|
7
|
+
if (event === "PreToolUse") {
|
|
8
|
+
return {
|
|
9
|
+
exitCode: 0,
|
|
10
|
+
stdout: JSON.stringify({
|
|
11
|
+
systemMessage: `agent-ops: ${result.code}`
|
|
12
|
+
})
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
exitCode: 0,
|
|
17
|
+
stdout: JSON.stringify({
|
|
18
|
+
continue: result.action !== "block",
|
|
19
|
+
...(result.action === "block"
|
|
20
|
+
? { stopReason: result.code }
|
|
21
|
+
: { systemMessage: `agent-ops: ${result.code}` })
|
|
22
|
+
})
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function explainConfig(merged) {
|
|
2
|
+
return {
|
|
3
|
+
schemaVersion: merged.config.schemaVersion,
|
|
4
|
+
profiles: merged.provenance.profiles.map((entry) => ({
|
|
5
|
+
id: entry.value,
|
|
6
|
+
source: entry.source,
|
|
7
|
+
sourcePath: entry.sourcePath
|
|
8
|
+
})),
|
|
9
|
+
verificationCommands: merged.provenance.verificationCommands.map((entry) => ({
|
|
10
|
+
id: entry.value.id,
|
|
11
|
+
required: entry.value.required,
|
|
12
|
+
shell: entry.value.shell === true,
|
|
13
|
+
evidence: {
|
|
14
|
+
kind: entry.value.evidence.kind,
|
|
15
|
+
minimum: entry.value.evidence.minimum ?? null
|
|
16
|
+
},
|
|
17
|
+
source: entry.source,
|
|
18
|
+
sourcePath: entry.sourcePath
|
|
19
|
+
})),
|
|
20
|
+
pathMappings: merged.provenance.pathMappings.map((entry) => ({
|
|
21
|
+
path: entry.value.path,
|
|
22
|
+
verifierIds: [...entry.value.verifierIds],
|
|
23
|
+
source: entry.source,
|
|
24
|
+
sourcePath: entry.sourcePath
|
|
25
|
+
})),
|
|
26
|
+
securityExceptions: merged.provenance.securityExceptions.map((entry) => ({
|
|
27
|
+
ruleId: entry.value.ruleId,
|
|
28
|
+
scope: entry.value.scope,
|
|
29
|
+
expiresAt: entry.value.expiresAt,
|
|
30
|
+
source: entry.source,
|
|
31
|
+
sourcePath: entry.sourcePath
|
|
32
|
+
}))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
3
|
+
import { previewConfigMigration } from "./migrate.js";
|
|
4
|
+
export async function loadConfigFile(path) {
|
|
5
|
+
let source;
|
|
6
|
+
try {
|
|
7
|
+
source = await readFile(path, "utf8");
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
throw new AgentOpsError("CONFIG_READ_FAILED", `Unable to read config: ${path}`, { cause: error });
|
|
11
|
+
}
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(source);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw new AgentOpsError("CONFIG_PARSE_FAILED", `Unable to parse config JSON: ${path}`, { cause: error });
|
|
18
|
+
}
|
|
19
|
+
const preview = previewConfigMigration(parsed);
|
|
20
|
+
return {
|
|
21
|
+
config: preview.migrated,
|
|
22
|
+
sourcePath: path,
|
|
23
|
+
migration: { steps: preview.steps }
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
import { validateConfig } from "../schema/validate.js";
|
|
3
|
+
const SOURCE_RANK = {
|
|
4
|
+
default: 0,
|
|
5
|
+
user: 1,
|
|
6
|
+
project: 2
|
|
7
|
+
};
|
|
8
|
+
function clone(value) {
|
|
9
|
+
return structuredClone(value);
|
|
10
|
+
}
|
|
11
|
+
function mappingKey(mapping) {
|
|
12
|
+
return mapping.path.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
function exceptionKey(exception) {
|
|
15
|
+
return `${exception.ruleId}\0${exception.scope.toLowerCase()}`;
|
|
16
|
+
}
|
|
17
|
+
function sameException(left, right) {
|
|
18
|
+
return (left.ruleId === right.ruleId &&
|
|
19
|
+
left.scope === right.scope &&
|
|
20
|
+
left.expiresAt === right.expiresAt &&
|
|
21
|
+
left.reason === right.reason);
|
|
22
|
+
}
|
|
23
|
+
function assertUniqueKeys(values, keyOf, layer, collection) {
|
|
24
|
+
const keys = new Set();
|
|
25
|
+
for (const value of values) {
|
|
26
|
+
const key = keyOf(value);
|
|
27
|
+
if (keys.has(key)) {
|
|
28
|
+
throw new AgentOpsError("CONFIG_DUPLICATE_ID", `Duplicate ${collection} stable ID in ${layer.source} config.`);
|
|
29
|
+
}
|
|
30
|
+
keys.add(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function assertLayer(layer) {
|
|
34
|
+
const validation = validateConfig(layer.config);
|
|
35
|
+
if (!validation.ok) {
|
|
36
|
+
const first = validation.errors[0];
|
|
37
|
+
throw new AgentOpsError(first?.code === "DUPLICATE_ID"
|
|
38
|
+
? "CONFIG_DUPLICATE_ID"
|
|
39
|
+
: "CONFIG_INVALID", `Invalid ${layer.source} config${first === undefined ? "." : `: ${first.code} at ${first.path}.`}`);
|
|
40
|
+
}
|
|
41
|
+
assertUniqueKeys(layer.config.verification.commands, (command) => command.id, layer, "verification command");
|
|
42
|
+
assertUniqueKeys(layer.config.pathMappings, mappingKey, layer, "path mapping");
|
|
43
|
+
assertUniqueKeys(layer.config.securityExceptions, exceptionKey, layer, "security exception");
|
|
44
|
+
}
|
|
45
|
+
function assertProjectCommandIsMonotonic(existing, candidate) {
|
|
46
|
+
if (existing === undefined ||
|
|
47
|
+
existing.source === "project") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const previous = existing.value;
|
|
51
|
+
const previousMinimum = previous.evidence.minimum ?? 0;
|
|
52
|
+
const candidateMinimum = candidate.evidence.minimum ?? 0;
|
|
53
|
+
const weakensRequired = previous.required && !candidate.required;
|
|
54
|
+
const enablesShell = previous.shell !== true && candidate.shell === true;
|
|
55
|
+
const changesProtectedExecution = previous.required &&
|
|
56
|
+
(previous.command !== candidate.command ||
|
|
57
|
+
previous.cwd !== candidate.cwd ||
|
|
58
|
+
(previous.shell === true) !== (candidate.shell === true) ||
|
|
59
|
+
previous.args.length !== candidate.args.length ||
|
|
60
|
+
previous.args.some((argument, index) => argument !== candidate.args[index]));
|
|
61
|
+
const lowersEvidence = previous.required &&
|
|
62
|
+
(previous.evidence.kind !== candidate.evidence.kind ||
|
|
63
|
+
candidateMinimum < previousMinimum);
|
|
64
|
+
const weakensTimeout = previous.required &&
|
|
65
|
+
previous.timeoutMs !== undefined &&
|
|
66
|
+
(candidate.timeoutMs === undefined ||
|
|
67
|
+
candidate.timeoutMs > previous.timeoutMs);
|
|
68
|
+
if (weakensRequired ||
|
|
69
|
+
enablesShell ||
|
|
70
|
+
changesProtectedExecution ||
|
|
71
|
+
lowersEvidence ||
|
|
72
|
+
weakensTimeout) {
|
|
73
|
+
throw new AgentOpsError("PROJECT_GUARDRAIL_WEAKENING", `Project command cannot weaken protected verifier ID: ${candidate.id}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function assertProjectMappingIsMonotonic(existing, candidate) {
|
|
77
|
+
if (existing === undefined || existing.source === "project") {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const candidateIds = new Set(candidate.verifierIds);
|
|
81
|
+
if (existing.value.path !== candidate.path ||
|
|
82
|
+
existing.value.verifierIds.some((verifierId) => !candidateIds.has(verifierId))) {
|
|
83
|
+
throw new AgentOpsError("PROJECT_GUARDRAIL_WEAKENING", `Project mapping cannot drop verifier coverage for: ${candidate.path}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function effective(value, layer) {
|
|
87
|
+
return {
|
|
88
|
+
value: clone(value),
|
|
89
|
+
source: layer.source,
|
|
90
|
+
sourcePath: layer.sourcePath
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function mergeConfigLayers(inputLayers) {
|
|
94
|
+
if (inputLayers.length === 0) {
|
|
95
|
+
throw new AgentOpsError("CONFIG_LAYER_REQUIRED", "At least one configuration layer is required.");
|
|
96
|
+
}
|
|
97
|
+
const sources = new Set();
|
|
98
|
+
for (const layer of inputLayers) {
|
|
99
|
+
if (sources.has(layer.source)) {
|
|
100
|
+
throw new AgentOpsError("CONFIG_LAYER_DUPLICATE", `Configuration source may appear only once: ${layer.source}`);
|
|
101
|
+
}
|
|
102
|
+
sources.add(layer.source);
|
|
103
|
+
assertLayer(layer);
|
|
104
|
+
}
|
|
105
|
+
const layers = [...inputLayers].sort((left, right) => SOURCE_RANK[left.source] - SOURCE_RANK[right.source]);
|
|
106
|
+
const profiles = new Map();
|
|
107
|
+
const commands = new Map();
|
|
108
|
+
const mappings = new Map();
|
|
109
|
+
const exceptions = new Map();
|
|
110
|
+
let schemaVersion;
|
|
111
|
+
for (const layer of layers) {
|
|
112
|
+
schemaVersion = effective(layer.config.schemaVersion, layer);
|
|
113
|
+
for (const profile of layer.config.profiles) {
|
|
114
|
+
profiles.set(profile, effective(profile, layer));
|
|
115
|
+
}
|
|
116
|
+
for (const command of layer.config.verification.commands) {
|
|
117
|
+
if (layer.source === "project") {
|
|
118
|
+
assertProjectCommandIsMonotonic(commands.get(command.id), command);
|
|
119
|
+
}
|
|
120
|
+
commands.set(command.id, effective(command, layer));
|
|
121
|
+
}
|
|
122
|
+
for (const mapping of layer.config.pathMappings) {
|
|
123
|
+
const key = mappingKey(mapping);
|
|
124
|
+
if (layer.source === "project") {
|
|
125
|
+
assertProjectMappingIsMonotonic(mappings.get(key), mapping);
|
|
126
|
+
}
|
|
127
|
+
mappings.set(key, effective(mapping, layer));
|
|
128
|
+
}
|
|
129
|
+
for (const securityException of layer.config.securityExceptions) {
|
|
130
|
+
const key = exceptionKey(securityException);
|
|
131
|
+
const existing = exceptions.get(key);
|
|
132
|
+
if (layer.source === "project") {
|
|
133
|
+
if (existing === undefined ||
|
|
134
|
+
!sameException(existing.value, securityException)) {
|
|
135
|
+
throw new AgentOpsError("PROJECT_SECURITY_WEAKENING", `Project config cannot authorize security exception: ${securityException.ruleId}`);
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
exceptions.set(key, effective(securityException, layer));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (schemaVersion === undefined) {
|
|
143
|
+
throw new AgentOpsError("CONFIG_LAYER_REQUIRED", "At least one configuration layer is required.");
|
|
144
|
+
}
|
|
145
|
+
const provenance = {
|
|
146
|
+
schemaVersion,
|
|
147
|
+
profiles: [...profiles.values()],
|
|
148
|
+
verificationCommands: [...commands.values()],
|
|
149
|
+
pathMappings: [...mappings.values()],
|
|
150
|
+
securityExceptions: [...exceptions.values()]
|
|
151
|
+
};
|
|
152
|
+
const config = {
|
|
153
|
+
schemaVersion: 1,
|
|
154
|
+
profiles: provenance.profiles.map(({ value }) => value),
|
|
155
|
+
verification: {
|
|
156
|
+
commands: provenance.verificationCommands.map(({ value }) => value)
|
|
157
|
+
},
|
|
158
|
+
pathMappings: provenance.pathMappings.map(({ value }) => value),
|
|
159
|
+
securityExceptions: provenance.securityExceptions.map(({ value }) => value)
|
|
160
|
+
};
|
|
161
|
+
const validation = validateConfig(config);
|
|
162
|
+
if (!validation.ok) {
|
|
163
|
+
const first = validation.errors[0];
|
|
164
|
+
throw new AgentOpsError("CONFIG_MERGE_INVALID", `Merged config is invalid${first === undefined ? "." : `: ${first.code} at ${first.path}.`}`);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
config: clone(validation.value),
|
|
168
|
+
provenance: clone(provenance)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SCHEMA_VERSION } from "../contracts.js";
|
|
2
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
3
|
+
import { validateConfig } from "../schema/validate.js";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function clone(value) {
|
|
8
|
+
return structuredClone(value);
|
|
9
|
+
}
|
|
10
|
+
const MIGRATIONS = new Map([
|
|
11
|
+
[
|
|
12
|
+
0,
|
|
13
|
+
(input) => {
|
|
14
|
+
const { commands, schemaVersion: _schemaVersion, ...rest } = input;
|
|
15
|
+
return {
|
|
16
|
+
...rest,
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
verification: { commands: clone(commands) }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
]);
|
|
23
|
+
function schemaVersionOf(value) {
|
|
24
|
+
if (!isRecord(value) ||
|
|
25
|
+
!Number.isSafeInteger(value.schemaVersion) ||
|
|
26
|
+
value.schemaVersion < 0) {
|
|
27
|
+
throw new AgentOpsError("CONFIG_SCHEMA_INVALID", "Config schemaVersion must be a non-negative integer.");
|
|
28
|
+
}
|
|
29
|
+
return value.schemaVersion;
|
|
30
|
+
}
|
|
31
|
+
export function previewConfigMigration(input) {
|
|
32
|
+
const sourceVersion = schemaVersionOf(input);
|
|
33
|
+
if (sourceVersion > SCHEMA_VERSION) {
|
|
34
|
+
throw new AgentOpsError("CONFIG_SCHEMA_FUTURE", `Config schemaVersion ${sourceVersion} is newer than supported version ${SCHEMA_VERSION}.`);
|
|
35
|
+
}
|
|
36
|
+
let migrated = clone(input);
|
|
37
|
+
let version = sourceVersion;
|
|
38
|
+
const steps = [];
|
|
39
|
+
while (version < SCHEMA_VERSION) {
|
|
40
|
+
const migration = MIGRATIONS.get(version);
|
|
41
|
+
if (migration === undefined || !isRecord(migrated)) {
|
|
42
|
+
throw new AgentOpsError("CONFIG_MIGRATION_MISSING", `No migration is registered from config schemaVersion ${version}.`);
|
|
43
|
+
}
|
|
44
|
+
const nextVersion = version + 1;
|
|
45
|
+
migrated = migration(clone(migrated));
|
|
46
|
+
if (schemaVersionOf(migrated) !== nextVersion) {
|
|
47
|
+
throw new AgentOpsError("CONFIG_MIGRATION_INVALID", `Migration ${version} must produce schemaVersion ${nextVersion}.`);
|
|
48
|
+
}
|
|
49
|
+
steps.push({ fromVersion: version, toVersion: nextVersion });
|
|
50
|
+
version = nextVersion;
|
|
51
|
+
}
|
|
52
|
+
const validation = validateConfig(migrated);
|
|
53
|
+
if (!validation.ok) {
|
|
54
|
+
const first = validation.errors[0];
|
|
55
|
+
throw new AgentOpsError("CONFIG_INVALID", `Migrated config is invalid${first === undefined ? "." : `: ${first.code} at ${first.path}.`}`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
migrated: clone(validation.value),
|
|
59
|
+
steps
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SCHEMA_VERSION = 1;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const MAX_GO_MOD_BYTES = 1024 * 1024;
|
|
5
|
+
function hasErrorCode(error, code) {
|
|
6
|
+
return (error instanceof Error &&
|
|
7
|
+
"code" in error &&
|
|
8
|
+
error.code === code);
|
|
9
|
+
}
|
|
10
|
+
async function readGoMod(filePath) {
|
|
11
|
+
let status;
|
|
12
|
+
try {
|
|
13
|
+
status = await lstat(filePath);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
17
|
+
return { kind: "missing" };
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
kind: "invalid",
|
|
21
|
+
message: "go.mod could not be inspected safely."
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (status.isSymbolicLink() || !status.isFile()) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "invalid",
|
|
27
|
+
message: "go.mod must be a regular file."
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (status.size > MAX_GO_MOD_BYTES) {
|
|
31
|
+
return {
|
|
32
|
+
kind: "invalid",
|
|
33
|
+
message: "go.mod exceeds the discovery size limit."
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
let handle;
|
|
37
|
+
try {
|
|
38
|
+
handle = await open(filePath, constants.O_RDONLY |
|
|
39
|
+
constants.O_NOFOLLOW |
|
|
40
|
+
constants.O_NONBLOCK);
|
|
41
|
+
const openedStatus = await handle.stat();
|
|
42
|
+
if (!openedStatus.isFile()) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "invalid",
|
|
45
|
+
message: "go.mod must remain a regular file."
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let totalBytes = 0;
|
|
50
|
+
while (totalBytes <= MAX_GO_MOD_BYTES) {
|
|
51
|
+
const remaining = MAX_GO_MOD_BYTES + 1 - totalBytes;
|
|
52
|
+
const buffer = Buffer.alloc(Math.min(64 * 1024, remaining));
|
|
53
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
54
|
+
if (bytesRead === 0) {
|
|
55
|
+
return {
|
|
56
|
+
kind: "source",
|
|
57
|
+
source: Buffer.concat(chunks, totalBytes).toString("utf8")
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
61
|
+
totalBytes += bytesRead;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
kind: "invalid",
|
|
65
|
+
message: "go.mod exceeds the discovery size limit."
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return {
|
|
70
|
+
kind: "invalid",
|
|
71
|
+
message: "go.mod could not be read as a regular file."
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await handle?.close().catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function hasOneModuleDirective(source) {
|
|
79
|
+
if (source.includes("\0") || source.includes("\uFFFD")) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const moduleLines = source.split(/\r?\n/u).filter((line) => (/^\s*module(?:\s|$)/u.test(line)));
|
|
83
|
+
return (moduleLines.length === 1 &&
|
|
84
|
+
/^\s*module\s+\S+\s*(?:(?:\/\/).*)?$/u.test(moduleLines[0] ?? ""));
|
|
85
|
+
}
|
|
86
|
+
function fileEvidence(detail) {
|
|
87
|
+
return { kind: "file", path: "go.mod", detail };
|
|
88
|
+
}
|
|
89
|
+
function userDecision(reason, message, evidence) {
|
|
90
|
+
return {
|
|
91
|
+
kind: "user-decision",
|
|
92
|
+
adapter: "go",
|
|
93
|
+
reason,
|
|
94
|
+
message,
|
|
95
|
+
evidence,
|
|
96
|
+
manualConfigAllowed: true
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function goTestProposal(evidence) {
|
|
100
|
+
return {
|
|
101
|
+
id: "go:test",
|
|
102
|
+
command: "go",
|
|
103
|
+
args: ["test", "./..."],
|
|
104
|
+
cwd: ".",
|
|
105
|
+
required: true,
|
|
106
|
+
evidence: { kind: "test-count", minimum: 1 },
|
|
107
|
+
sourceEvidence: evidence,
|
|
108
|
+
confidence: "high",
|
|
109
|
+
confirmed: false
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function discoverGoProject(root) {
|
|
113
|
+
const metadata = await readGoMod(path.join(root, "go.mod"));
|
|
114
|
+
if (metadata.kind === "missing") {
|
|
115
|
+
return {
|
|
116
|
+
kind: "no-match",
|
|
117
|
+
adapter: "go",
|
|
118
|
+
reason: "not-go-project",
|
|
119
|
+
message: "No go.mod was found.",
|
|
120
|
+
evidence: [],
|
|
121
|
+
manualConfigAllowed: true
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const evidence = [fileEvidence("Found bounded Go module metadata.")];
|
|
125
|
+
if (metadata.kind === "invalid") {
|
|
126
|
+
return userDecision("invalid-manifest", metadata.message, evidence);
|
|
127
|
+
}
|
|
128
|
+
if (!hasOneModuleDirective(metadata.source)) {
|
|
129
|
+
return userDecision("invalid-manifest", "go.mod does not contain one valid module directive.", evidence);
|
|
130
|
+
}
|
|
131
|
+
const proposalEvidence = [
|
|
132
|
+
fileEvidence("Found one explicit Go module directive.")
|
|
133
|
+
];
|
|
134
|
+
return {
|
|
135
|
+
kind: "proposals",
|
|
136
|
+
adapter: "go",
|
|
137
|
+
proposals: [goTestProposal(proposalEvidence)],
|
|
138
|
+
evidence: proposalEvidence,
|
|
139
|
+
manualConfigAllowed: true
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export const goDiscoveryAdapter = {
|
|
143
|
+
id: "go",
|
|
144
|
+
discover: discoverGoProject
|
|
145
|
+
};
|