@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,9 @@
|
|
|
1
|
+
import { evaluateDestructiveCommand } from "./destructive.js";
|
|
2
|
+
import { applySecurityExceptions } from "./exceptions.js";
|
|
3
|
+
import { evaluateSecretContent } from "./secrets.js";
|
|
4
|
+
export function evaluateGuardrail(input, options = {}) {
|
|
5
|
+
const decision = input.kind === "content"
|
|
6
|
+
? evaluateSecretContent(input.content, input.scope)
|
|
7
|
+
: evaluateDestructiveCommand(input);
|
|
8
|
+
return applySecurityExceptions(decision, input.scope, options);
|
|
9
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
|
|
2
|
+
const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
|
|
3
|
+
function isBoundedProjectScope(scope) {
|
|
4
|
+
if (scope.length === 0 ||
|
|
5
|
+
scope === "." ||
|
|
6
|
+
scope.includes("\0") ||
|
|
7
|
+
scope.includes("\\") ||
|
|
8
|
+
scope.includes("*") ||
|
|
9
|
+
scope.includes("?") ||
|
|
10
|
+
scope.includes("[") ||
|
|
11
|
+
scope.startsWith("/") ||
|
|
12
|
+
/^[A-Za-z]:/.test(scope)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return scope.split("/").every((segment) => segment.length > 0 &&
|
|
16
|
+
segment !== "." &&
|
|
17
|
+
segment !== ".." &&
|
|
18
|
+
!segment.endsWith(".") &&
|
|
19
|
+
!WINDOWS_RESERVED_SEGMENT.test(segment) &&
|
|
20
|
+
/^[A-Za-z0-9._-]+$/.test(segment));
|
|
21
|
+
}
|
|
22
|
+
function scopeContains(exceptionScope, evaluatedScope) {
|
|
23
|
+
return (evaluatedScope === exceptionScope ||
|
|
24
|
+
evaluatedScope.startsWith(`${exceptionScope}/`));
|
|
25
|
+
}
|
|
26
|
+
export function isActiveSecurityException(exception, ruleId, scope, now) {
|
|
27
|
+
const expiresAt = Date.parse(exception.expiresAt);
|
|
28
|
+
return (ID_PATTERN.test(exception.ruleId) &&
|
|
29
|
+
exception.ruleId === ruleId &&
|
|
30
|
+
isBoundedProjectScope(exception.scope) &&
|
|
31
|
+
isBoundedProjectScope(scope) &&
|
|
32
|
+
scopeContains(exception.scope, scope) &&
|
|
33
|
+
exception.reason.trim().length > 0 &&
|
|
34
|
+
Number.isFinite(expiresAt) &&
|
|
35
|
+
Number.isFinite(now.getTime()) &&
|
|
36
|
+
expiresAt > now.getTime());
|
|
37
|
+
}
|
|
38
|
+
export function applySecurityExceptions(decision, scope, options) {
|
|
39
|
+
if (decision.action === "allow" || options.exceptions === undefined) {
|
|
40
|
+
return decision;
|
|
41
|
+
}
|
|
42
|
+
const now = options.now;
|
|
43
|
+
if (now === undefined) {
|
|
44
|
+
return decision;
|
|
45
|
+
}
|
|
46
|
+
return options.exceptions.some((exception) => isActiveSecurityException(exception, decision.ruleId, scope, now))
|
|
47
|
+
? { action: "allow" }
|
|
48
|
+
: decision;
|
|
49
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { GUARDRAIL_RULE_IDS } from "./types.js";
|
|
2
|
+
const PRIVATE_KEY_BLOCK = /-----BEGIN (PGP PRIVATE KEY BLOCK|(?:[A-Z0-9]+ )?PRIVATE KEY)-----([\s\S]*?)-----END \1-----/g;
|
|
3
|
+
const KNOWN_TOKEN = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g;
|
|
4
|
+
const SENSITIVE_ASSIGNMENT = /\b(?:[a-z0-9]+[_-])*(?:access[_-]?token|api[_-]?key|client[_-]?secret|private[_-]?key|secret[_-]?access[_-]?key|password|token)\b["']?\s*[:=]\s*(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s"'&;,}]+))/gi;
|
|
5
|
+
const AUTHORIZATION_VALUE = /\bAuthorization\s*:\s*(?:Bearer|Basic|token)\s+([^\s,;]+)/gi;
|
|
6
|
+
const PLACEHOLDER = /^(?:\[(?:REDACTED|REMOVED)(?:[_ -][A-Z0-9]+)*\]|<[^>]*(?:example|placeholder|redacted|replace|your)[^>]*>|(?:change-?me|example|placeholder|redacted|replace-?me|sample)|x{6,})$/i;
|
|
7
|
+
const SYNTHETIC_DOCUMENTATION_LABEL = /^\s*(?:[#/*-]+\s*)?synthetic documentation example\s*:/i;
|
|
8
|
+
function isPlaceholder(value) {
|
|
9
|
+
return PLACEHOLDER.test(value.trim());
|
|
10
|
+
}
|
|
11
|
+
function hasHighEntropy(value) {
|
|
12
|
+
if (value.length < 24 || value.length > 4_096 || /\s/.test(value)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const frequencies = new Map();
|
|
16
|
+
for (const character of value) {
|
|
17
|
+
frequencies.set(character, (frequencies.get(character) ?? 0) + 1);
|
|
18
|
+
}
|
|
19
|
+
let entropy = 0;
|
|
20
|
+
for (const count of frequencies.values()) {
|
|
21
|
+
const probability = count / value.length;
|
|
22
|
+
entropy -= probability * Math.log2(probability);
|
|
23
|
+
}
|
|
24
|
+
return frequencies.size >= 8 && entropy >= 3;
|
|
25
|
+
}
|
|
26
|
+
function blocksPrivateKey(content) {
|
|
27
|
+
for (const match of content.matchAll(PRIVATE_KEY_BLOCK)) {
|
|
28
|
+
const body = match[2]?.trim() ?? "";
|
|
29
|
+
if (!isPlaceholder(body)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
function blocksKnownToken(content) {
|
|
36
|
+
for (const match of content.matchAll(KNOWN_TOKEN)) {
|
|
37
|
+
const token = match[0];
|
|
38
|
+
if (!isPlaceholder(token)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
function blocksSensitiveValue(content) {
|
|
45
|
+
for (const match of content.matchAll(SENSITIVE_ASSIGNMENT)) {
|
|
46
|
+
const value = match[1] ?? match[2] ?? match[3] ?? "";
|
|
47
|
+
if (!isPlaceholder(value) && hasHighEntropy(value)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const match of content.matchAll(AUTHORIZATION_VALUE)) {
|
|
52
|
+
const value = match[1] ?? "";
|
|
53
|
+
if (!isPlaceholder(value) && hasHighEntropy(value)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function isDocumentationScope(scope) {
|
|
60
|
+
const normalized = scope.toLowerCase();
|
|
61
|
+
const segments = normalized.split("/");
|
|
62
|
+
const filename = segments.at(-1) ?? "";
|
|
63
|
+
return (segments.includes("docs") ||
|
|
64
|
+
segments.includes("documentation") ||
|
|
65
|
+
/^readme(?:\.|$)/.test(filename) ||
|
|
66
|
+
/\.(?:adoc|md|mdx|rst)$/.test(filename));
|
|
67
|
+
}
|
|
68
|
+
function documentationExampleDecision(decision, content, scope) {
|
|
69
|
+
if (decision.action !== "block" ||
|
|
70
|
+
!isDocumentationScope(scope) ||
|
|
71
|
+
!SYNTHETIC_DOCUMENTATION_LABEL.test(content)) {
|
|
72
|
+
return decision;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
action: "warn",
|
|
76
|
+
ruleId: GUARDRAIL_RULE_IDS.documentationExample,
|
|
77
|
+
reason: "A labeled synthetic documentation example contains credential-shaped material."
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function evaluateSecretContent(content, scope = "") {
|
|
81
|
+
let decision = { action: "allow" };
|
|
82
|
+
if (blocksPrivateKey(content)) {
|
|
83
|
+
decision = {
|
|
84
|
+
action: "block",
|
|
85
|
+
ruleId: GUARDRAIL_RULE_IDS.privateKey,
|
|
86
|
+
reason: "Private-key material must not be persisted or transmitted."
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
else if (blocksKnownToken(content) || blocksSensitiveValue(content)) {
|
|
90
|
+
decision = {
|
|
91
|
+
action: "block",
|
|
92
|
+
ruleId: GUARDRAIL_RULE_IDS.credential,
|
|
93
|
+
reason: "Credential-shaped material must be replaced with a redacted value."
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return documentationExampleDecision(decision, content, scope);
|
|
97
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const GUARDRAIL_RULE_IDS = {
|
|
2
|
+
ambiguousTarget: "destructive-ambiguous-target",
|
|
3
|
+
broadDelete: "destructive-broad-delete",
|
|
4
|
+
credential: "secret-credential",
|
|
5
|
+
documentationExample: "secret-documentation-example",
|
|
6
|
+
forcePush: "destructive-force-push",
|
|
7
|
+
privateKey: "secret-private-key",
|
|
8
|
+
reset: "destructive-reset"
|
|
9
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
2
|
+
import { runStopVerification } from "./stop-verify.js";
|
|
3
|
+
function continueWith(status, code) {
|
|
4
|
+
return { action: "continue", status, code };
|
|
5
|
+
}
|
|
6
|
+
function guardrailResult(decision) {
|
|
7
|
+
if (decision.action === "block") {
|
|
8
|
+
return {
|
|
9
|
+
action: "block",
|
|
10
|
+
status: "FAIL",
|
|
11
|
+
code: decision.ruleId
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
if (decision.action === "warn") {
|
|
15
|
+
return continueWith("UNKNOWN", decision.ruleId);
|
|
16
|
+
}
|
|
17
|
+
return continueWith("PASS", "GUARDRAIL_ALLOWED");
|
|
18
|
+
}
|
|
19
|
+
function evaluateCommands(commands, scope) {
|
|
20
|
+
let warning = null;
|
|
21
|
+
for (const command of commands) {
|
|
22
|
+
const result = guardrailResult(evaluateGuardrail({
|
|
23
|
+
kind: "command",
|
|
24
|
+
command: command.command,
|
|
25
|
+
args: command.args,
|
|
26
|
+
scope
|
|
27
|
+
}));
|
|
28
|
+
if (result.action === "block") {
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
if (result.status === "UNKNOWN") {
|
|
32
|
+
warning = result;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return warning ?? continueWith("PASS", "GUARDRAIL_ALLOWED");
|
|
36
|
+
}
|
|
37
|
+
export async function dispatchHookEvent(event, options) {
|
|
38
|
+
if (event.event === "unsupported") {
|
|
39
|
+
return continueWith("UNKNOWN", "HOOK_EVENT_UNSUPPORTED");
|
|
40
|
+
}
|
|
41
|
+
if (event.event === "session-start" &&
|
|
42
|
+
options.capabilities.includes("lifecycle-summary") &&
|
|
43
|
+
options.advisory !== undefined) {
|
|
44
|
+
try {
|
|
45
|
+
await options.advisory(event);
|
|
46
|
+
return continueWith("PASS", "ADVISORY_FINISHED");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return continueWith("UNKNOWN", "ADVISORY_FAILED");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if ((event.event === "command" ||
|
|
53
|
+
event.event === "command-batch" ||
|
|
54
|
+
event.event === "content") &&
|
|
55
|
+
options.capabilities.includes("command-policy")) {
|
|
56
|
+
if (event.event === "content") {
|
|
57
|
+
return guardrailResult(evaluateGuardrail({
|
|
58
|
+
kind: "content",
|
|
59
|
+
content: event.content,
|
|
60
|
+
scope: event.scope
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
return evaluateCommands(event.event === "command"
|
|
64
|
+
? [{ command: event.command, args: event.args }]
|
|
65
|
+
: event.commands, event.scope);
|
|
66
|
+
}
|
|
67
|
+
if (event.event === "stop" &&
|
|
68
|
+
options.capabilities.includes("optional-stop-verify")) {
|
|
69
|
+
if (options.stopVerification === undefined) {
|
|
70
|
+
return continueWith("UNKNOWN", "STOP_VERIFICATION_UNAVAILABLE");
|
|
71
|
+
}
|
|
72
|
+
return await runStopVerification({
|
|
73
|
+
...options.stopVerification,
|
|
74
|
+
trusted: options.trusted && options.stopVerification.trusted
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return continueWith("PASS", "HOOK_NOOP");
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { dispatchHookEvent } from "./dispatch.js";
|
|
2
|
+
import { normalizeHookEvent } from "./normalize.js";
|
|
3
|
+
function invalidInput() {
|
|
4
|
+
return {
|
|
5
|
+
action: "continue",
|
|
6
|
+
status: "UNKNOWN",
|
|
7
|
+
code: "HOOK_INPUT_INVALID"
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export async function runHookEntry(stdin, options) {
|
|
11
|
+
let input;
|
|
12
|
+
try {
|
|
13
|
+
input = JSON.parse(stdin);
|
|
14
|
+
return await dispatchHookEvent(normalizeHookEvent(input), options);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return invalidInput();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
const MAX_TEXT_LENGTH = 4096;
|
|
3
|
+
const MAX_ARGS = 256;
|
|
4
|
+
function invalidInput() {
|
|
5
|
+
throw new AgentOpsError("HOOK_INPUT_INVALID", "Hook input is invalid.");
|
|
6
|
+
}
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function boundedString(value) {
|
|
11
|
+
if (typeof value !== "string" ||
|
|
12
|
+
value.length === 0 ||
|
|
13
|
+
value.length > MAX_TEXT_LENGTH ||
|
|
14
|
+
value.includes("\0")) {
|
|
15
|
+
return invalidInput();
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function stringArgs(value) {
|
|
20
|
+
if (!Array.isArray(value) ||
|
|
21
|
+
value.length > MAX_ARGS ||
|
|
22
|
+
!value.every((item) => typeof item === "string" &&
|
|
23
|
+
item.length <= MAX_TEXT_LENGTH &&
|
|
24
|
+
!item.includes("\0"))) {
|
|
25
|
+
return invalidInput();
|
|
26
|
+
}
|
|
27
|
+
return [...value];
|
|
28
|
+
}
|
|
29
|
+
export function normalizeHookEvent(input) {
|
|
30
|
+
if (!isRecord(input)) {
|
|
31
|
+
return invalidInput();
|
|
32
|
+
}
|
|
33
|
+
const event = boundedString(input.event);
|
|
34
|
+
const projectRoot = boundedString(input.projectRoot);
|
|
35
|
+
if (event === "session-start" || event === "stop") {
|
|
36
|
+
return { event, projectRoot };
|
|
37
|
+
}
|
|
38
|
+
if (event === "command") {
|
|
39
|
+
return {
|
|
40
|
+
event,
|
|
41
|
+
projectRoot,
|
|
42
|
+
command: boundedString(input.command),
|
|
43
|
+
args: stringArgs(input.args),
|
|
44
|
+
scope: boundedString(input.scope)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (event === "content") {
|
|
48
|
+
return {
|
|
49
|
+
event,
|
|
50
|
+
projectRoot,
|
|
51
|
+
content: boundedString(input.content),
|
|
52
|
+
scope: boundedString(input.scope)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
event: "unsupported",
|
|
57
|
+
projectRoot
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
export const DEFAULT_HOOK_OUTPUT_BYTES = 8 * 1024;
|
|
3
|
+
export function encodeHookOutput(output, maxBytes = DEFAULT_HOOK_OUTPUT_BYTES) {
|
|
4
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
5
|
+
throw new AgentOpsError("HOOK_OUTPUT_LIMIT_INVALID", "Hook output limit must be a positive safe integer.");
|
|
6
|
+
}
|
|
7
|
+
const encoded = JSON.stringify(output);
|
|
8
|
+
if (Buffer.byteLength(encoded, "utf8") > maxBytes) {
|
|
9
|
+
throw new AgentOpsError("HOOK_OUTPUT_TOO_LARGE", "Hook output exceeds the configured byte limit.");
|
|
10
|
+
}
|
|
11
|
+
return encoded;
|
|
12
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { normalizeHookEvent } from "./normalize.js";
|
|
2
|
+
const MAX_COMMAND_LENGTH = 16 * 1024;
|
|
3
|
+
const MAX_COMMANDS = 64;
|
|
4
|
+
const MAX_WORDS = 256;
|
|
5
|
+
function parseShellWords(input) {
|
|
6
|
+
if (input.length === 0 || input.length > MAX_COMMAND_LENGTH) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const commands = [];
|
|
10
|
+
let words = [];
|
|
11
|
+
let word = "";
|
|
12
|
+
let quote = null;
|
|
13
|
+
let escaped = false;
|
|
14
|
+
const finishWord = () => {
|
|
15
|
+
if (word.length > 0) {
|
|
16
|
+
words.push(word);
|
|
17
|
+
word = "";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const finishCommand = () => {
|
|
21
|
+
finishWord();
|
|
22
|
+
if (words.length > 0) {
|
|
23
|
+
commands.push(words);
|
|
24
|
+
words = [];
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
28
|
+
const character = input[index];
|
|
29
|
+
if (character === undefined) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (escaped) {
|
|
33
|
+
word += character;
|
|
34
|
+
escaped = false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (character === "\\" && quote !== "'") {
|
|
38
|
+
escaped = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (quote !== "'" &&
|
|
42
|
+
(character === "$" || character === "`")) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (quote !== null) {
|
|
46
|
+
if (character === quote) {
|
|
47
|
+
quote = null;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
word += character;
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (character === "'" || character === "\"") {
|
|
55
|
+
quote = character;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (/\s/.test(character)) {
|
|
59
|
+
finishWord();
|
|
60
|
+
if (character === "\n") {
|
|
61
|
+
finishCommand();
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (character === ";" || character === "|" || character === "&") {
|
|
66
|
+
finishCommand();
|
|
67
|
+
const next = input[index + 1];
|
|
68
|
+
if (next === character) {
|
|
69
|
+
index += 1;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (character === "(" ||
|
|
74
|
+
character === ")" ||
|
|
75
|
+
character === "\0") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
word += character;
|
|
79
|
+
if (word.length > MAX_COMMAND_LENGTH) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (escaped || quote !== null) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
finishCommand();
|
|
87
|
+
if (commands.length === 0 ||
|
|
88
|
+
commands.length > MAX_COMMANDS ||
|
|
89
|
+
commands.some((command) => command.length > MAX_WORDS)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return commands;
|
|
93
|
+
}
|
|
94
|
+
export function normalizeShellHookEvent(input, projectRoot) {
|
|
95
|
+
const commands = parseShellWords(input);
|
|
96
|
+
if (commands === null) {
|
|
97
|
+
return normalizeHookEvent({
|
|
98
|
+
event: "unsupported",
|
|
99
|
+
projectRoot
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const normalized = commands.map((words) => {
|
|
103
|
+
const command = words[0];
|
|
104
|
+
if (command === undefined) {
|
|
105
|
+
throw new Error("Shell parser returned an empty command.");
|
|
106
|
+
}
|
|
107
|
+
const event = normalizeHookEvent({
|
|
108
|
+
event: "command",
|
|
109
|
+
projectRoot,
|
|
110
|
+
command,
|
|
111
|
+
args: words.slice(1),
|
|
112
|
+
scope: projectRoot
|
|
113
|
+
});
|
|
114
|
+
if (event.event !== "command") {
|
|
115
|
+
throw new Error("Shell command normalization failed.");
|
|
116
|
+
}
|
|
117
|
+
return event;
|
|
118
|
+
});
|
|
119
|
+
if (normalized.length === 1 && normalized[0] !== undefined) {
|
|
120
|
+
return normalized[0];
|
|
121
|
+
}
|
|
122
|
+
const first = normalized[0];
|
|
123
|
+
if (first === undefined) {
|
|
124
|
+
return normalizeHookEvent({
|
|
125
|
+
event: "unsupported",
|
|
126
|
+
projectRoot
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
event: "command-batch",
|
|
131
|
+
projectRoot: first.projectRoot,
|
|
132
|
+
commands: normalized.map(({ command, args }) => ({
|
|
133
|
+
command,
|
|
134
|
+
args
|
|
135
|
+
})),
|
|
136
|
+
scope: first.scope
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
|
2
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
3
|
+
function unavailable(code) {
|
|
4
|
+
return {
|
|
5
|
+
action: "continue",
|
|
6
|
+
status: "UNKNOWN",
|
|
7
|
+
code
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function boundedEvidence(results) {
|
|
11
|
+
if (results.length > 256 ||
|
|
12
|
+
results.some((result) => !ID_PATTERN.test(result.commandId) ||
|
|
13
|
+
(result.exitCode !== null &&
|
|
14
|
+
(!Number.isSafeInteger(result.exitCode) || result.exitCode < 0)) ||
|
|
15
|
+
(result.testCount !== null &&
|
|
16
|
+
(!Number.isSafeInteger(result.testCount) || result.testCount < 0)))) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return results.map(({ commandId, exitCode, testCount }) => ({
|
|
20
|
+
commandId,
|
|
21
|
+
exitCode,
|
|
22
|
+
testCount
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
function evidenceSupportsStatus(status, results) {
|
|
26
|
+
if (status !== "PASS") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return (results.length > 0 &&
|
|
30
|
+
results.some((result) => result.testCount !== null && result.testCount > 0) &&
|
|
31
|
+
results.every((result) => result.exitCode === 0 &&
|
|
32
|
+
(result.testCount === null || result.testCount > 0)));
|
|
33
|
+
}
|
|
34
|
+
export async function runStopVerification(options) {
|
|
35
|
+
if (options.recursionMarker) {
|
|
36
|
+
return unavailable("STOP_VERIFICATION_RECURSION");
|
|
37
|
+
}
|
|
38
|
+
if (!options.confirmedConfig || !HASH_PATTERN.test(options.configHash)) {
|
|
39
|
+
return unavailable("STOP_VERIFICATION_UNCONFIRMED");
|
|
40
|
+
}
|
|
41
|
+
if (!options.trusted) {
|
|
42
|
+
return unavailable("STOP_VERIFICATION_UNTRUSTED");
|
|
43
|
+
}
|
|
44
|
+
if (!options.scopeMapped) {
|
|
45
|
+
return unavailable("STOP_VERIFICATION_SCOPE_UNMAPPED");
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const report = await options.verify();
|
|
49
|
+
const commandResults = boundedEvidence(report.results);
|
|
50
|
+
const timestamp = (options.now ?? (() => new Date().toISOString()))();
|
|
51
|
+
if (commandResults === null ||
|
|
52
|
+
!evidenceSupportsStatus(report.status, commandResults) ||
|
|
53
|
+
!Number.isFinite(Date.parse(timestamp))) {
|
|
54
|
+
return unavailable("STOP_VERIFICATION_EVIDENCE_INVALID");
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
action: "continue",
|
|
58
|
+
status: report.status,
|
|
59
|
+
code: "STOP_VERIFICATION_FINISHED",
|
|
60
|
+
evidence: {
|
|
61
|
+
commandResults,
|
|
62
|
+
configHash: options.configHash,
|
|
63
|
+
timestamp
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return unavailable("STOP_VERIFICATION_FAILED");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { sha256 } from "../fs/hash.js";
|
|
3
|
+
import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
4
|
+
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
5
|
+
import { FileTransaction } from "../fs/transaction.js";
|
|
6
|
+
import { validateConfig } from "../schema/validate.js";
|
|
7
|
+
const CONFIG_PATH = ".agent-ops/config.json";
|
|
8
|
+
const MANIFEST_PATH = ".agent-ops/manifest.json";
|
|
9
|
+
async function readInstalledFile(root, path) {
|
|
10
|
+
return await readFile(await resolveContainedPath(root, path), "utf8");
|
|
11
|
+
}
|
|
12
|
+
function markerCount(source, marker) {
|
|
13
|
+
let count = 0;
|
|
14
|
+
let index = 0;
|
|
15
|
+
while ((index = source.indexOf(marker, index)) !== -1) {
|
|
16
|
+
count += 1;
|
|
17
|
+
index += marker.length;
|
|
18
|
+
}
|
|
19
|
+
return count;
|
|
20
|
+
}
|
|
21
|
+
async function validateAppliedPlan(root, plan) {
|
|
22
|
+
const installedManifestSource = await readInstalledFile(root, MANIFEST_PATH);
|
|
23
|
+
const installedManifest = parseInstallManifest(installedManifestSource);
|
|
24
|
+
if (formatInstallManifest(installedManifest) !==
|
|
25
|
+
formatInstallManifest(plan.manifest)) {
|
|
26
|
+
throw new AgentOpsError("INSTALL_VALIDATION_FAILED", "The installed manifest does not match the approved plan.");
|
|
27
|
+
}
|
|
28
|
+
for (const artifact of installedManifest.artifacts) {
|
|
29
|
+
const content = await readInstalledFile(root, artifact.path);
|
|
30
|
+
if (sha256(content) !== artifact.hash) {
|
|
31
|
+
throw new AgentOpsError("INSTALL_VALIDATION_FAILED", `Installed artifact hash mismatch: ${artifact.path}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const marker of installedManifest.markers) {
|
|
35
|
+
const content = await readInstalledFile(root, marker.path);
|
|
36
|
+
if (sha256(content) !== marker.hash ||
|
|
37
|
+
markerCount(content, marker.startMarker) !== 1 ||
|
|
38
|
+
markerCount(content, marker.endMarker) !== 1 ||
|
|
39
|
+
content.indexOf(marker.startMarker) >=
|
|
40
|
+
content.indexOf(marker.endMarker)) {
|
|
41
|
+
throw new AgentOpsError("INSTALL_VALIDATION_FAILED", `Installed managed block is invalid: ${marker.path}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const configSource = await readInstalledFile(root, CONFIG_PATH);
|
|
45
|
+
let config;
|
|
46
|
+
try {
|
|
47
|
+
config = JSON.parse(configSource);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new AgentOpsError("INSTALL_VALIDATION_FAILED", "Installed configuration is not valid JSON.", { cause: error });
|
|
51
|
+
}
|
|
52
|
+
const configResult = validateConfig(config);
|
|
53
|
+
if (!configResult.ok) {
|
|
54
|
+
throw new AgentOpsError("INSTALL_VALIDATION_FAILED", `${configResult.errors[0]?.path ?? "$"}: ${configResult.errors[0]?.message ?? "Invalid installed configuration."}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function assertManifestOperation(plan) {
|
|
58
|
+
const manifestOperations = plan.operations.filter((operation) => operation.path === MANIFEST_PATH);
|
|
59
|
+
const manifestOperation = manifestOperations[0];
|
|
60
|
+
if (manifestOperations.length !== 1 ||
|
|
61
|
+
manifestOperation?.kind !== "write" ||
|
|
62
|
+
manifestOperation.content !== formatInstallManifest(plan.manifest)) {
|
|
63
|
+
throw new AgentOpsError("INVALID_INSTALL_PLAN", "Install plan must contain one matching manifest write.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function applyInstallPlan(root, plan) {
|
|
67
|
+
assertManifestOperation(plan);
|
|
68
|
+
const transaction = new FileTransaction(root);
|
|
69
|
+
await transaction.apply({ operations: plan.operations }, async () => await validateAppliedPlan(root, plan));
|
|
70
|
+
}
|