@deftai/directive-core 0.88.0 → 0.89.0
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/dist/authz/actions.d.ts +55 -0
- package/dist/authz/actions.js +125 -0
- package/dist/authz/classify.d.ts +23 -0
- package/dist/authz/classify.js +217 -0
- package/dist/authz/closed-verb.d.ts +42 -0
- package/dist/authz/closed-verb.js +279 -0
- package/dist/authz/evaluate.d.ts +41 -0
- package/dist/authz/evaluate.js +298 -0
- package/dist/authz/index.d.ts +15 -0
- package/dist/authz/index.js +15 -0
- package/dist/authz/origin.d.ts +28 -0
- package/dist/authz/origin.js +64 -0
- package/dist/authz/paths.d.ts +6 -0
- package/dist/authz/paths.js +19 -0
- package/dist/authz/store.d.ts +40 -0
- package/dist/authz/store.js +317 -0
- package/dist/authz/templates.d.ts +139 -0
- package/dist/authz/templates.js +241 -0
- package/dist/authz/types.d.ts +111 -0
- package/dist/authz/types.js +41 -0
- package/dist/authz/verb-classification.d.ts +44 -0
- package/dist/authz/verb-classification.js +237 -0
- package/dist/branch/evaluate.js +6 -1
- package/dist/cache/fetch.js +10 -5
- package/dist/cache/io.d.ts +9 -2
- package/dist/cache/io.js +30 -10
- package/dist/cache/task-cache/store.js +11 -7
- package/dist/capacity/backfill.js +10 -4
- package/dist/category-b-namespace/index.js +10 -4
- package/dist/codebase/default-extractor.js +3 -0
- package/dist/codebase/map.js +11 -4
- package/dist/doctor/doctor-state.js +28 -4
- package/dist/doctor/main.d.ts +10 -0
- package/dist/doctor/main.js +160 -0
- package/dist/doctor/types.d.ts +11 -0
- package/dist/escalation/actions.d.ts +63 -0
- package/dist/escalation/actions.js +137 -0
- package/dist/escalation/index.d.ts +8 -0
- package/dist/escalation/index.js +8 -0
- package/dist/escalation/paths.d.ts +3 -0
- package/dist/escalation/paths.js +10 -0
- package/dist/escalation/store.d.ts +17 -0
- package/dist/escalation/store.js +172 -0
- package/dist/escalation/types.d.ts +73 -0
- package/dist/escalation/types.js +43 -0
- package/dist/eval/crud-telemetry.js +30 -10
- package/dist/eval/health.js +22 -7
- package/dist/eval/readback.js +11 -10
- package/dist/eval/run.js +32 -16
- package/dist/events/attribution-enrichment.js +10 -4
- package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
- package/dist/finish-loop/directive-finish-loop.js +239 -0
- package/dist/finish-loop/grant-gate.d.ts +31 -0
- package/dist/finish-loop/grant-gate.js +169 -0
- package/dist/finish-loop/index.d.ts +11 -0
- package/dist/finish-loop/index.js +11 -0
- package/dist/finish-loop/main.d.ts +35 -0
- package/dist/finish-loop/main.js +365 -0
- package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
- package/dist/finish-loop/pr-finish-loop.js +217 -0
- package/dist/finish-loop/progress.d.ts +17 -0
- package/dist/finish-loop/progress.js +45 -0
- package/dist/finish-loop/queue.d.ts +16 -0
- package/dist/finish-loop/queue.js +65 -0
- package/dist/finish-loop/types.d.ts +52 -0
- package/dist/finish-loop/types.js +10 -0
- package/dist/fs/contained-write.d.ts +101 -0
- package/dist/fs/contained-write.js +281 -0
- package/dist/hooks/classify/classify.d.ts +10 -0
- package/dist/hooks/classify/classify.js +37 -0
- package/dist/hooks/classify/index.d.ts +14 -0
- package/dist/hooks/classify/index.js +13 -0
- package/dist/hooks/classify/paths.d.ts +22 -0
- package/dist/hooks/classify/paths.js +72 -0
- package/dist/hooks/classify/payload.d.ts +16 -0
- package/dist/hooks/classify/payload.js +45 -0
- package/dist/hooks/classify/stdin.d.ts +12 -0
- package/dist/hooks/classify/stdin.js +63 -0
- package/dist/hooks/classify/tool-name.d.ts +18 -0
- package/dist/hooks/classify/tool-name.js +85 -0
- package/dist/hooks/classify/types.d.ts +41 -0
- package/dist/hooks/classify/types.js +7 -0
- package/dist/hooks/dispatcher.d.ts +21 -18
- package/dist/hooks/dispatcher.js +212 -163
- package/dist/hooks/fixtures/cases.d.ts +44 -0
- package/dist/hooks/fixtures/cases.js +595 -0
- package/dist/hooks/fixtures/index.d.ts +2 -0
- package/dist/hooks/fixtures/index.js +2 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/init-deposit/agent-hooks.js +27 -8
- package/dist/init-deposit/gitignore.js +11 -3
- package/dist/init-deposit/headless-manifest.js +12 -4
- package/dist/init-deposit/migrate.d.ts +1 -1
- package/dist/init-deposit/migrate.js +13 -3
- package/dist/init-deposit/prettierignore.js +10 -3
- package/dist/init-deposit/scaffold.js +35 -26
- package/dist/init-deposit/xbrief-projections.js +11 -4
- package/dist/intake/candidates-log.js +46 -35
- package/dist/intake/github-body-cli.d.ts +6 -0
- package/dist/intake/github-body-cli.js +17 -0
- package/dist/intake/github-body.d.ts +46 -3
- package/dist/intake/github-body.js +191 -20
- package/dist/intake/issue-emit.d.ts +1 -0
- package/dist/intake/issue-emit.js +41 -23
- package/dist/lifecycle/event-detect.js +12 -4
- package/dist/lifecycle/events.js +29 -20
- package/dist/lifecycle/lifecycle-hygiene.js +9 -3
- package/dist/packs/pack-render.js +19 -6
- package/dist/plan-sequence/store.js +11 -4
- package/dist/policy/hotfix-criteria.d.ts +79 -0
- package/dist/policy/hotfix-criteria.js +197 -0
- package/dist/policy/index.d.ts +4 -0
- package/dist/policy/index.js +31 -1
- package/dist/policy/intent-ceiling.d.ts +79 -0
- package/dist/policy/intent-ceiling.js +181 -0
- package/dist/policy/no-deft-directive.js +10 -3
- package/dist/policy/org-force-on-migration.js +9 -5
- package/dist/policy/require-human-merge.d.ts +75 -0
- package/dist/policy/require-human-merge.js +324 -0
- package/dist/policy/resolve.js +17 -6
- package/dist/policy/runtime-authority.d.ts +15 -2
- package/dist/policy/runtime-authority.js +23 -3
- package/dist/policy/write-fence.d.ts +78 -0
- package/dist/policy/write-fence.js +164 -0
- package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
- package/dist/pr-wait-mergeable/cascade.js +28 -0
- package/dist/preflight/evaluate.js +10 -0
- package/dist/product-signal/consent.js +21 -5
- package/dist/product-signal/submit.js +22 -12
- package/dist/release/build-dist-runner.js +5 -2
- package/dist/release/build-dist.d.ts +19 -1
- package/dist/release/build-dist.js +50 -2
- package/dist/release/native-steps.js +7 -2
- package/dist/release-publish/pipeline.d.ts +13 -0
- package/dist/release-publish/pipeline.js +46 -1
- package/dist/scope/audit-log.js +19 -24
- package/dist/scope/decompose.js +10 -5
- package/dist/scope/decomposed-refs.js +18 -3
- package/dist/scope/demote.js +9 -2
- package/dist/scope/undo.js +9 -2
- package/dist/session/release-availability.js +26 -6
- package/dist/session/ritual-sentinel.js +19 -12
- package/dist/session/session-start.js +7 -0
- package/dist/staleness-tickler/state.js +26 -6
- package/dist/swarm/launch.js +13 -3
- package/dist/swarm/routing.js +9 -3
- package/dist/task-surface/index.js +24 -9
- package/dist/triage/bootstrap/gitignore.js +53 -10
- package/dist/triage/cache-path.js +10 -3
- package/dist/triage/welcome/summary.js +11 -4
- package/dist/triage/welcome/writers.js +45 -16
- package/dist/validate-content/validate-links.js +5 -0
- package/dist/value/readback.js +11 -6
- package/dist/vbrief-activate/activate.js +12 -4
- package/dist/vbrief-build/project-definition-io.js +14 -6
- package/dist/verify-source/contained-writes.d.ts +59 -0
- package/dist/verify-source/contained-writes.js +272 -0
- package/dist/verify-source/index.d.ts +1 -0
- package/dist/verify-source/index.js +1 -0
- package/dist/verify-source/openclaw-tier1.js +14 -1
- package/dist/verify-source/verify-stubs.js +3 -0
- package/dist/xbrief-migrate/migrate-project.js +26 -12
- package/dist/xbrief-migrate/transforms.d.ts +4 -2
- package/dist/xbrief-migrate/transforms.js +37 -14
- package/package.json +15 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope queue scan for directive:finish-loop (#871).
|
|
3
|
+
* Lists xbrief/active + xbrief/pending (and legacy vbrief/) story files.
|
|
4
|
+
* Implementation work is agent-owned; this is the gate's empty-queue detector.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
const LIFECYCLE_DIRS = [
|
|
9
|
+
["xbrief", "active"],
|
|
10
|
+
["xbrief", "pending"],
|
|
11
|
+
["vbrief", "active"],
|
|
12
|
+
["vbrief", "pending"],
|
|
13
|
+
];
|
|
14
|
+
function isStoryFile(name) {
|
|
15
|
+
const lower = name.toLowerCase();
|
|
16
|
+
return (lower.endsWith(".xbrief.json") ||
|
|
17
|
+
lower.endsWith(".vbrief.json") ||
|
|
18
|
+
(lower.endsWith(".json") && (lower.includes("xbrief") || lower.includes("vbrief"))));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Scan project root for in-flight / pending story xBRIEFs.
|
|
22
|
+
* Does not invent work — empty means the outer loop may halt cleanly.
|
|
23
|
+
*/
|
|
24
|
+
export function scanFinishLoopQueue(projectRoot) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const [rootName, lifecycle] of LIFECYCLE_DIRS) {
|
|
28
|
+
const dir = join(projectRoot, rootName, lifecycle);
|
|
29
|
+
if (!existsSync(dir))
|
|
30
|
+
continue;
|
|
31
|
+
let names;
|
|
32
|
+
try {
|
|
33
|
+
names = readdirSync(dir);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
if (!isStoryFile(name))
|
|
40
|
+
continue;
|
|
41
|
+
// Skip PROJECT-DEFINITION and similar non-story files in wrong folders
|
|
42
|
+
if (name.toUpperCase().includes("PROJECT-DEFINITION"))
|
|
43
|
+
continue;
|
|
44
|
+
const full = join(dir, name);
|
|
45
|
+
try {
|
|
46
|
+
if (!statSync(full).isFile())
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = full.replace(/\\/g, "/").toLowerCase();
|
|
53
|
+
if (seen.has(key))
|
|
54
|
+
continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
out.push({
|
|
57
|
+
path: full,
|
|
58
|
+
lifecycle: lifecycle,
|
|
59
|
+
name,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=queue.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk-away finish-loop types (#871 Wave 5 / #2948).
|
|
3
|
+
*/
|
|
4
|
+
import type { AuthzDecisionCode } from "../authz/types.js";
|
|
5
|
+
/** Three-state + blocked exit codes for finish-loop surfaces. */
|
|
6
|
+
export declare const EXIT_OK = 0;
|
|
7
|
+
/** NEW_P0_P1 / address-needed / agent step required (non-terminal product halt). */
|
|
8
|
+
export declare const EXIT_ACTION_REQUIRED = 1;
|
|
9
|
+
/** BLOCKED (grant/gate), ERRORED, TIMEOUT, config. */
|
|
10
|
+
export declare const EXIT_BLOCKED = 2;
|
|
11
|
+
export type FinishLoopHaltReason = "empty-queue" | "grant-missing" | "grant-expired" | "grant-deny" | "gate-deny" | "max-iterations" | "require-human-merge" | "address-findings" | "agent-implement" | "clean" | "merged" | "error";
|
|
12
|
+
export type FinishLoopPhase = "gate" | "queue-scan" | "implement" | "pr-open" | "pr-watch" | "address" | "merge" | "halt";
|
|
13
|
+
export interface FinishLoopProgressLine {
|
|
14
|
+
readonly schemaVersion: 1;
|
|
15
|
+
readonly ts: string;
|
|
16
|
+
readonly phase: FinishLoopPhase;
|
|
17
|
+
readonly iteration: number;
|
|
18
|
+
readonly haltReason: FinishLoopHaltReason | null;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly prNumber: number | null;
|
|
21
|
+
readonly grantId: string | null;
|
|
22
|
+
readonly queueCount: number | null;
|
|
23
|
+
readonly exitCode: number | null;
|
|
24
|
+
readonly extra?: Readonly<Record<string, unknown>>;
|
|
25
|
+
}
|
|
26
|
+
export interface FinishLoopGrantGateResult {
|
|
27
|
+
readonly allowed: boolean;
|
|
28
|
+
readonly code: AuthzDecisionCode | "finish-loop-env-bypass" | "finish-loop-allow";
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
readonly grantId: string | null;
|
|
31
|
+
readonly missingOps: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
export interface PrFinishLoopResult {
|
|
34
|
+
readonly exitCode: number;
|
|
35
|
+
readonly haltReason: FinishLoopHaltReason;
|
|
36
|
+
readonly message: string;
|
|
37
|
+
readonly prNumber: number;
|
|
38
|
+
readonly watchVerdict: string | null;
|
|
39
|
+
readonly mergeAttempted: boolean;
|
|
40
|
+
readonly mergeSkippedReason: string | null;
|
|
41
|
+
readonly grantId: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface DirectiveFinishLoopResult {
|
|
44
|
+
readonly exitCode: number;
|
|
45
|
+
readonly haltReason: FinishLoopHaltReason;
|
|
46
|
+
readonly message: string;
|
|
47
|
+
readonly iterations: number;
|
|
48
|
+
readonly queueCount: number;
|
|
49
|
+
readonly grantId: string | null;
|
|
50
|
+
readonly progressPath: string;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk-away finish-loop types (#871 Wave 5 / #2948).
|
|
3
|
+
*/
|
|
4
|
+
/** Three-state + blocked exit codes for finish-loop surfaces. */
|
|
5
|
+
export const EXIT_OK = 0;
|
|
6
|
+
/** NEW_P0_P1 / address-needed / agent step required (non-terminal product halt). */
|
|
7
|
+
export const EXIT_ACTION_REQUIRED = 1;
|
|
8
|
+
/** BLOCKED (grant/gate), ERRORED, TIMEOUT, config. */
|
|
9
|
+
export const EXIT_BLOCKED = 2;
|
|
10
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contained-write.ts — mandatory contained-write API for product sinks (#2951 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Single primitive that resolves a path under an explicit root, refuses symlink
|
|
5
|
+
* escape / out-of-root targets (via {@link assertWriteTargetSafe}), then writes
|
|
6
|
+
* with an explicit mode: create | replace | append.
|
|
7
|
+
*
|
|
8
|
+
* Hard rules:
|
|
9
|
+
* - Final write target must resolve **inside** `root` after normalization.
|
|
10
|
+
* - Symlink escape (or leaf symlink on the write path) → fail closed with a
|
|
11
|
+
* stable error code.
|
|
12
|
+
* - No silent fallback to raw write on failure.
|
|
13
|
+
*
|
|
14
|
+
* Prefer this API for all new product write sinks. Prefer migrating call sites
|
|
15
|
+
* onto this API over bespoke per-sink checks when equivalent. See
|
|
16
|
+
* `docs/reference/contained-write.md`.
|
|
17
|
+
*
|
|
18
|
+
* Refs #2951.
|
|
19
|
+
*/
|
|
20
|
+
/** Stable machine-readable error codes for contained-write refusals. */
|
|
21
|
+
export declare const ContainedWriteErrorCode: {
|
|
22
|
+
/** Target path escapes the containment root (`..` or absolute outside root). */
|
|
23
|
+
readonly ESCAPE: "CONTAINED_WRITE_ESCAPE";
|
|
24
|
+
/** Target or intermediate path is a symlink (leaf or escaping). */
|
|
25
|
+
readonly SYMLINK: "CONTAINED_WRITE_SYMLINK";
|
|
26
|
+
/** `mode: "create"` but the target already exists. */
|
|
27
|
+
readonly EXISTS: "CONTAINED_WRITE_EXISTS";
|
|
28
|
+
/** `mode: "append"` or replace semantics need an existing file that is missing (reserved). */
|
|
29
|
+
readonly NOT_FOUND: "CONTAINED_WRITE_NOT_FOUND";
|
|
30
|
+
/** Containment root does not exist or cannot be realpath'd. */
|
|
31
|
+
readonly ROOT_MISSING: "CONTAINED_WRITE_ROOT_MISSING";
|
|
32
|
+
/** Caller passed an unsupported mode value. */
|
|
33
|
+
readonly INVALID_MODE: "CONTAINED_WRITE_INVALID_MODE";
|
|
34
|
+
/** Underlying filesystem I/O failure after containment passed. */
|
|
35
|
+
readonly IO: "CONTAINED_WRITE_IO";
|
|
36
|
+
};
|
|
37
|
+
export type ContainedWriteErrorCode = (typeof ContainedWriteErrorCode)[keyof typeof ContainedWriteErrorCode];
|
|
38
|
+
/** Explicit write modes for {@link containedWrite}. */
|
|
39
|
+
export type ContainedWriteMode = "create" | "replace" | "append";
|
|
40
|
+
/** Thrown when a contained write is refused or fails after containment. */
|
|
41
|
+
export declare class ContainedWriteError extends Error {
|
|
42
|
+
readonly code: ContainedWriteErrorCode;
|
|
43
|
+
readonly root: string;
|
|
44
|
+
readonly target: string;
|
|
45
|
+
readonly offendingPath: string;
|
|
46
|
+
constructor(message: string, details: {
|
|
47
|
+
code: ContainedWriteErrorCode;
|
|
48
|
+
root: string;
|
|
49
|
+
target: string;
|
|
50
|
+
offendingPath?: string;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export interface ContainedWriteInput {
|
|
54
|
+
/** Absolute containment root (project root, deposit root, or sandbox). */
|
|
55
|
+
readonly root: string;
|
|
56
|
+
/**
|
|
57
|
+
* Write target. Relative paths are resolved under `root`. Absolute paths
|
|
58
|
+
* must stay under `root` after normalization.
|
|
59
|
+
*/
|
|
60
|
+
readonly target: string;
|
|
61
|
+
/** Bytes or UTF-8 string payload. */
|
|
62
|
+
readonly data: string | Buffer;
|
|
63
|
+
/** create = fail if exists; replace = truncate/write; append = append. */
|
|
64
|
+
readonly mode: ContainedWriteMode;
|
|
65
|
+
/**
|
|
66
|
+
* Encoding when `data` is a string. Default `"utf8"`.
|
|
67
|
+
* Ignored when `data` is a Buffer.
|
|
68
|
+
*/
|
|
69
|
+
readonly encoding?: BufferEncoding;
|
|
70
|
+
/**
|
|
71
|
+
* Create parent directories under `root` when missing. Default `true`.
|
|
72
|
+
* Parents are still subject to containment (no escape via mkdir).
|
|
73
|
+
*/
|
|
74
|
+
readonly mkdir?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* When true, follow symlinks that resolve inside root (not recommended).
|
|
77
|
+
* Default `false` — leaf and path-component symlinks are refused via
|
|
78
|
+
* {@link assertWriteTargetSafe}.
|
|
79
|
+
*/
|
|
80
|
+
readonly followSymlinks?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface ContainedWriteResult {
|
|
83
|
+
/** Absolute path written. */
|
|
84
|
+
readonly path: string;
|
|
85
|
+
/** Number of bytes written in this call (not total file size for append). */
|
|
86
|
+
readonly bytesWritten: number;
|
|
87
|
+
readonly mode: ContainedWriteMode;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve `target` under `root`. Relative targets join root; absolute targets
|
|
91
|
+
* must already be nested under root (path-segment containment).
|
|
92
|
+
*/
|
|
93
|
+
export declare function resolveContainedTarget(root: string, target: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* Contained write: resolve under root, refuse symlink escape / out-of-root,
|
|
96
|
+
* then write with the requested mode.
|
|
97
|
+
*
|
|
98
|
+
* @throws {ContainedWriteError} on containment refusal or mode violation
|
|
99
|
+
*/
|
|
100
|
+
export declare function containedWrite(input: ContainedWriteInput): ContainedWriteResult;
|
|
101
|
+
//# sourceMappingURL=contained-write.d.ts.map
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contained-write.ts — mandatory contained-write API for product sinks (#2951 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Single primitive that resolves a path under an explicit root, refuses symlink
|
|
5
|
+
* escape / out-of-root targets (via {@link assertWriteTargetSafe}), then writes
|
|
6
|
+
* with an explicit mode: create | replace | append.
|
|
7
|
+
*
|
|
8
|
+
* Hard rules:
|
|
9
|
+
* - Final write target must resolve **inside** `root` after normalization.
|
|
10
|
+
* - Symlink escape (or leaf symlink on the write path) → fail closed with a
|
|
11
|
+
* stable error code.
|
|
12
|
+
* - No silent fallback to raw write on failure.
|
|
13
|
+
*
|
|
14
|
+
* Prefer this API for all new product write sinks. Prefer migrating call sites
|
|
15
|
+
* onto this API over bespoke per-sink checks when equivalent. See
|
|
16
|
+
* `docs/reference/contained-write.md`.
|
|
17
|
+
*
|
|
18
|
+
* Refs #2951.
|
|
19
|
+
*/
|
|
20
|
+
import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, realpathSync, writeSync, } from "node:fs";
|
|
21
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
22
|
+
import { assertWriteTargetSafe, ProjectionContainmentError } from "./projection-containment.js";
|
|
23
|
+
/** Stable machine-readable error codes for contained-write refusals. */
|
|
24
|
+
export const ContainedWriteErrorCode = {
|
|
25
|
+
/** Target path escapes the containment root (`..` or absolute outside root). */
|
|
26
|
+
ESCAPE: "CONTAINED_WRITE_ESCAPE",
|
|
27
|
+
/** Target or intermediate path is a symlink (leaf or escaping). */
|
|
28
|
+
SYMLINK: "CONTAINED_WRITE_SYMLINK",
|
|
29
|
+
/** `mode: "create"` but the target already exists. */
|
|
30
|
+
EXISTS: "CONTAINED_WRITE_EXISTS",
|
|
31
|
+
/** `mode: "append"` or replace semantics need an existing file that is missing (reserved). */
|
|
32
|
+
NOT_FOUND: "CONTAINED_WRITE_NOT_FOUND",
|
|
33
|
+
/** Containment root does not exist or cannot be realpath'd. */
|
|
34
|
+
ROOT_MISSING: "CONTAINED_WRITE_ROOT_MISSING",
|
|
35
|
+
/** Caller passed an unsupported mode value. */
|
|
36
|
+
INVALID_MODE: "CONTAINED_WRITE_INVALID_MODE",
|
|
37
|
+
/** Underlying filesystem I/O failure after containment passed. */
|
|
38
|
+
IO: "CONTAINED_WRITE_IO",
|
|
39
|
+
};
|
|
40
|
+
/** Thrown when a contained write is refused or fails after containment. */
|
|
41
|
+
export class ContainedWriteError extends Error {
|
|
42
|
+
code;
|
|
43
|
+
root;
|
|
44
|
+
target;
|
|
45
|
+
offendingPath;
|
|
46
|
+
constructor(message, details) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "ContainedWriteError";
|
|
49
|
+
this.code = details.code;
|
|
50
|
+
this.root = details.root;
|
|
51
|
+
this.target = details.target;
|
|
52
|
+
this.offendingPath = details.offendingPath ?? details.target;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const VALID_MODES = new Set(["create", "replace", "append"]);
|
|
56
|
+
/**
|
|
57
|
+
* Resolve `target` under `root`. Relative targets join root; absolute targets
|
|
58
|
+
* must already be nested under root (path-segment containment).
|
|
59
|
+
*/
|
|
60
|
+
export function resolveContainedTarget(root, target) {
|
|
61
|
+
const rootAbs = resolve(root);
|
|
62
|
+
if (target.length === 0) {
|
|
63
|
+
throw new ContainedWriteError("contained write refused: empty target path", {
|
|
64
|
+
code: ContainedWriteErrorCode.ESCAPE,
|
|
65
|
+
root: rootAbs,
|
|
66
|
+
target: "",
|
|
67
|
+
offendingPath: rootAbs,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const targetAbs = isAbsolute(target) ? resolve(target) : resolve(rootAbs, target);
|
|
71
|
+
const rel = relative(rootAbs, targetAbs);
|
|
72
|
+
if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) {
|
|
73
|
+
throw new ContainedWriteError(`contained write refused: target ${targetAbs} is not nested under root ${rootAbs}`, {
|
|
74
|
+
code: ContainedWriteErrorCode.ESCAPE,
|
|
75
|
+
root: rootAbs,
|
|
76
|
+
target: targetAbs,
|
|
77
|
+
offendingPath: targetAbs,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return targetAbs;
|
|
81
|
+
}
|
|
82
|
+
function mapProjectionError(err, rootAbs, targetAbs) {
|
|
83
|
+
const msg = err.message;
|
|
84
|
+
const isSymlink = /symlink/i.test(msg) ||
|
|
85
|
+
/broken\/dangling symlink/i.test(msg) ||
|
|
86
|
+
/is a symlink on the write path/i.test(msg);
|
|
87
|
+
return new ContainedWriteError(msg.replace(/^projection write refused:/i, "contained write refused:"), {
|
|
88
|
+
code: isSymlink ? ContainedWriteErrorCode.SYMLINK : ContainedWriteErrorCode.ESCAPE,
|
|
89
|
+
root: rootAbs,
|
|
90
|
+
target: targetAbs,
|
|
91
|
+
offendingPath: err.offendingPath,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function ensureParents(rootAbs, targetAbs) {
|
|
95
|
+
const parent = dirname(targetAbs);
|
|
96
|
+
if (parent === targetAbs || parent === rootAbs) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Contain parent before mkdir (mkdir recursive must not create outside root).
|
|
100
|
+
const rel = relative(rootAbs, parent);
|
|
101
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
102
|
+
throw new ContainedWriteError(`contained write refused: parent ${parent} is not nested under root ${rootAbs}`, {
|
|
103
|
+
code: ContainedWriteErrorCode.ESCAPE,
|
|
104
|
+
root: rootAbs,
|
|
105
|
+
target: targetAbs,
|
|
106
|
+
offendingPath: parent,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (!existsSync(parent)) {
|
|
110
|
+
try {
|
|
111
|
+
assertWriteTargetSafe(rootAbs, parent);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (err instanceof ProjectionContainmentError) {
|
|
115
|
+
throw mapProjectionError(err, rootAbs, targetAbs);
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
mkdirSync(parent, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
try {
|
|
123
|
+
assertWriteTargetSafe(rootAbs, parent);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err instanceof ProjectionContainmentError) {
|
|
127
|
+
throw mapProjectionError(err, rootAbs, targetAbs);
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function toBuffer(data, encoding) {
|
|
134
|
+
return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding);
|
|
135
|
+
}
|
|
136
|
+
function writeNoFollow(targetAbs, buf, flags) {
|
|
137
|
+
const fd = openSync(targetAbs, flags, 0o644);
|
|
138
|
+
try {
|
|
139
|
+
let offset = 0;
|
|
140
|
+
while (offset < buf.length) {
|
|
141
|
+
const n = writeSync(fd, buf, offset, buf.length - offset, null);
|
|
142
|
+
if (n <= 0) {
|
|
143
|
+
throw new Error(`short write: wrote ${offset} of ${buf.length} bytes to ${targetAbs}`);
|
|
144
|
+
}
|
|
145
|
+
offset += n;
|
|
146
|
+
}
|
|
147
|
+
return offset;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Contained write: resolve under root, refuse symlink escape / out-of-root,
|
|
155
|
+
* then write with the requested mode.
|
|
156
|
+
*
|
|
157
|
+
* @throws {ContainedWriteError} on containment refusal or mode violation
|
|
158
|
+
*/
|
|
159
|
+
export function containedWrite(input) {
|
|
160
|
+
if (!VALID_MODES.has(input.mode)) {
|
|
161
|
+
throw new ContainedWriteError(`contained write refused: invalid mode ${String(input.mode)} (expected create|replace|append)`, {
|
|
162
|
+
code: ContainedWriteErrorCode.INVALID_MODE,
|
|
163
|
+
root: resolve(input.root),
|
|
164
|
+
target: String(input.target),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (input.followSymlinks === true) {
|
|
168
|
+
throw new ContainedWriteError("contained write refused: followSymlinks=true is not supported in Phase 1 (always refuse symlinks)", {
|
|
169
|
+
code: ContainedWriteErrorCode.INVALID_MODE,
|
|
170
|
+
root: resolve(input.root),
|
|
171
|
+
target: String(input.target),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const rootAbs = resolve(input.root);
|
|
175
|
+
try {
|
|
176
|
+
realpathSync(rootAbs);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new ContainedWriteError(`contained write refused: root ${rootAbs} does not exist`, {
|
|
180
|
+
code: ContainedWriteErrorCode.ROOT_MISSING,
|
|
181
|
+
root: rootAbs,
|
|
182
|
+
target: String(input.target),
|
|
183
|
+
offendingPath: rootAbs,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const targetAbs = resolveContainedTarget(rootAbs, input.target);
|
|
187
|
+
try {
|
|
188
|
+
assertWriteTargetSafe(rootAbs, targetAbs);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err instanceof ProjectionContainmentError) {
|
|
192
|
+
throw mapProjectionError(err, rootAbs, targetAbs);
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
const doMkdir = input.mkdir !== false;
|
|
197
|
+
if (doMkdir) {
|
|
198
|
+
ensureParents(rootAbs, targetAbs);
|
|
199
|
+
}
|
|
200
|
+
// Re-check after mkdir (parent path could have been created as unexpected type).
|
|
201
|
+
try {
|
|
202
|
+
assertWriteTargetSafe(rootAbs, targetAbs);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (err instanceof ProjectionContainmentError) {
|
|
206
|
+
throw mapProjectionError(err, rootAbs, targetAbs);
|
|
207
|
+
}
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
const encoding = input.encoding ?? "utf8";
|
|
211
|
+
const buf = toBuffer(input.data, encoding);
|
|
212
|
+
let exists = false;
|
|
213
|
+
try {
|
|
214
|
+
lstatSync(targetAbs);
|
|
215
|
+
exists = true;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
exists = false;
|
|
219
|
+
}
|
|
220
|
+
if (input.mode === "create" && exists) {
|
|
221
|
+
throw new ContainedWriteError(`contained write refused: target ${targetAbs} already exists (mode=create)`, {
|
|
222
|
+
code: ContainedWriteErrorCode.EXISTS,
|
|
223
|
+
root: rootAbs,
|
|
224
|
+
target: targetAbs,
|
|
225
|
+
offendingPath: targetAbs,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
let bytesWritten;
|
|
230
|
+
if (input.mode === "append") {
|
|
231
|
+
bytesWritten = writeNoFollow(targetAbs, buf, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW);
|
|
232
|
+
}
|
|
233
|
+
else if (input.mode === "create") {
|
|
234
|
+
// O_EXCL refuses replace-if-exists; O_NOFOLLOW refuses symlink leaf.
|
|
235
|
+
bytesWritten = writeNoFollow(targetAbs, buf, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
// replace
|
|
239
|
+
bytesWritten = writeNoFollow(targetAbs, buf, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW);
|
|
240
|
+
}
|
|
241
|
+
return { path: targetAbs, bytesWritten, mode: input.mode };
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (err instanceof ContainedWriteError) {
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
248
|
+
// EEXIST from O_EXCL
|
|
249
|
+
if (typeof err === "object" &&
|
|
250
|
+
err !== null &&
|
|
251
|
+
"code" in err &&
|
|
252
|
+
err.code === "EEXIST") {
|
|
253
|
+
throw new ContainedWriteError(`contained write refused: target ${targetAbs} already exists (mode=create)`, {
|
|
254
|
+
code: ContainedWriteErrorCode.EXISTS,
|
|
255
|
+
root: rootAbs,
|
|
256
|
+
target: targetAbs,
|
|
257
|
+
offendingPath: targetAbs,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// ELOOP / EMLINK style symlink refusals from O_NOFOLLOW
|
|
261
|
+
if (typeof err === "object" &&
|
|
262
|
+
err !== null &&
|
|
263
|
+
"code" in err &&
|
|
264
|
+
(err.code === "ELOOP" ||
|
|
265
|
+
err.code === "EPERM")) {
|
|
266
|
+
throw new ContainedWriteError(`contained write refused: ${targetAbs} is a symlink on the write path (O_NOFOLLOW)`, {
|
|
267
|
+
code: ContainedWriteErrorCode.SYMLINK,
|
|
268
|
+
root: rootAbs,
|
|
269
|
+
target: targetAbs,
|
|
270
|
+
offendingPath: targetAbs,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
throw new ContainedWriteError(`contained write I/O failed: ${msg}`, {
|
|
274
|
+
code: ContainedWriteErrorCode.IO,
|
|
275
|
+
root: rootAbs,
|
|
276
|
+
target: targetAbs,
|
|
277
|
+
offendingPath: targetAbs,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=contained-write.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classify entry: payload → tool identity + write intent + paths (#2950 Phase A).
|
|
3
|
+
* No process I/O, no ritual/scope/policy evaluation.
|
|
4
|
+
*/
|
|
5
|
+
import type { ClassifyHookPayloadInput, HookClassification } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Classify a host hook payload without policy or filesystem access.
|
|
8
|
+
*/
|
|
9
|
+
export declare function classifyHookPayload(input: ClassifyHookPayloadInput): HookClassification;
|
|
10
|
+
//# sourceMappingURL=classify.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classify entry: payload → tool identity + write intent + paths (#2950 Phase A).
|
|
3
|
+
* No process I/O, no ritual/scope/policy evaluation.
|
|
4
|
+
*/
|
|
5
|
+
import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "../tools.js";
|
|
6
|
+
import { hookPathSet, hookShellCommand, hookWriteTargetPath } from "./paths.js";
|
|
7
|
+
import { hookPayloadTopLevelKeys } from "./payload.js";
|
|
8
|
+
import { hookToolName } from "./tool-name.js";
|
|
9
|
+
function writeIntentForTool(toolName) {
|
|
10
|
+
if (toolName === null)
|
|
11
|
+
return "unknown";
|
|
12
|
+
if (isDirectWriteTool(toolName))
|
|
13
|
+
return "direct-write";
|
|
14
|
+
if (isSpawnTool(toolName))
|
|
15
|
+
return "spawn";
|
|
16
|
+
if (isShellTool(toolName))
|
|
17
|
+
return "shell";
|
|
18
|
+
if (isMcpTool(toolName))
|
|
19
|
+
return "mcp";
|
|
20
|
+
return "other";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classify a host hook payload without policy or filesystem access.
|
|
24
|
+
*/
|
|
25
|
+
export function classifyHookPayload(input) {
|
|
26
|
+
const toolName = hookToolName(input.payload, input.host);
|
|
27
|
+
const writeTargetPath = hookWriteTargetPath(input.payload);
|
|
28
|
+
return {
|
|
29
|
+
toolName,
|
|
30
|
+
writeIntent: writeIntentForTool(toolName),
|
|
31
|
+
writeTargetPath,
|
|
32
|
+
shellCommand: hookShellCommand(input.payload),
|
|
33
|
+
topLevelKeys: hookPayloadTopLevelKeys(input.payload),
|
|
34
|
+
paths: hookPathSet(input.payload),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=classify.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure hook payload classification (#2950 Phase A).
|
|
3
|
+
*
|
|
4
|
+
* Payload → tool identity + write intent + path set. No process I/O.
|
|
5
|
+
* Policy / ritual / scope / permission emission stay in dispatcher + CLI adapter.
|
|
6
|
+
*/
|
|
7
|
+
export { classifyHookPayload } from "./classify.js";
|
|
8
|
+
export { hookMcpArgsText, hookPathSet, hookShellCommand, hookWriteTargetPath, } from "./paths.js";
|
|
9
|
+
export { fieldPresent, fieldString, firstString, hookPayloadTopLevelKeys, record, toolInputRecord, } from "./payload.js";
|
|
10
|
+
export { parseHookStdin, stripUtf8Bom } from "./stdin.js";
|
|
11
|
+
export { hookToolName, inferCursorDirectWriteToolName, type MissingToolNameInput, missingToolNameMessage, } from "./tool-name.js";
|
|
12
|
+
export type { ClassifyHookHost, ClassifyHookPayloadInput, HookClassification, HookPayloadContext, HookWriteIntent, ParsedHookPayload, } from "./types.js";
|
|
13
|
+
export { CLASSIFY_HOOK_HOSTS } from "./types.js";
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure hook payload classification (#2950 Phase A).
|
|
3
|
+
*
|
|
4
|
+
* Payload → tool identity + write intent + path set. No process I/O.
|
|
5
|
+
* Policy / ritual / scope / permission emission stay in dispatcher + CLI adapter.
|
|
6
|
+
*/
|
|
7
|
+
export { classifyHookPayload } from "./classify.js";
|
|
8
|
+
export { hookMcpArgsText, hookPathSet, hookShellCommand, hookWriteTargetPath, } from "./paths.js";
|
|
9
|
+
export { fieldPresent, fieldString, firstString, hookPayloadTopLevelKeys, record, toolInputRecord, } from "./payload.js";
|
|
10
|
+
export { parseHookStdin, stripUtf8Bom } from "./stdin.js";
|
|
11
|
+
export { hookToolName, inferCursorDirectWriteToolName, missingToolNameMessage, } from "./tool-name.js";
|
|
12
|
+
export { CLASSIFY_HOOK_HOSTS } from "./types.js";
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure write-target / shell-command extraction from host payloads (#2950).
|
|
3
|
+
* No process I/O — does not resolve against projectRoot or realpath.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Best-effort write-target path from host PreToolUse payloads (#2625).
|
|
7
|
+
* Hosts disagree on nesting (`tool_input.file_path` vs top-level `path`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function hookWriteTargetPath(payload: unknown): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Best-effort shell command string from host PreToolUse payloads (#2711).
|
|
12
|
+
* Hosts disagree on nesting (`tool_input.command` vs top-level `command`).
|
|
13
|
+
*/
|
|
14
|
+
export declare function hookShellCommand(payload: unknown): string | null;
|
|
15
|
+
/** Serialize tool args for MCP classification when nested objects are present (#2711). */
|
|
16
|
+
export declare function hookMcpArgsText(payload: unknown): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Collect distinct path-like strings from a payload for fixture assertions.
|
|
19
|
+
* Write target first when present; does not invent paths from free text.
|
|
20
|
+
*/
|
|
21
|
+
export declare function hookPathSet(payload: unknown): string[];
|
|
22
|
+
//# sourceMappingURL=paths.d.ts.map
|