@lannguyensi/harness 0.25.2 → 0.27.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli/approve/risk.d.ts +43 -0
  3. package/dist/cli/approve/risk.js +126 -0
  4. package/dist/cli/approve/risk.js.map +1 -0
  5. package/dist/cli/audit.js +8 -2
  6. package/dist/cli/audit.js.map +1 -1
  7. package/dist/cli/doctor/format.js +24 -0
  8. package/dist/cli/doctor/format.js.map +1 -1
  9. package/dist/cli/doctor/index.js +26 -0
  10. package/dist/cli/doctor/index.js.map +1 -1
  11. package/dist/cli/doctor/types.d.ts +23 -0
  12. package/dist/cli/event-input.d.ts +28 -0
  13. package/dist/cli/event-input.js +73 -0
  14. package/dist/cli/event-input.js.map +1 -0
  15. package/dist/cli/explain-action.d.ts +20 -0
  16. package/dist/cli/explain-action.js +27 -0
  17. package/dist/cli/explain-action.js.map +1 -0
  18. package/dist/cli/explain-policy.d.ts +54 -0
  19. package/dist/cli/explain-policy.js +81 -0
  20. package/dist/cli/explain-policy.js.map +1 -0
  21. package/dist/cli/explain.js +4 -0
  22. package/dist/cli/explain.js.map +1 -1
  23. package/dist/cli/index.js +126 -4
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/init/templates.d.ts +1 -1
  26. package/dist/cli/init/templates.js +98 -0
  27. package/dist/cli/init/templates.js.map +1 -1
  28. package/dist/cli/pack/hook-branch-protection.js +1 -1
  29. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  30. package/dist/cli/pack/hook-codex-pre-tool-use.js +1 -1
  31. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  32. package/dist/cli/pack/hook-post-tool-use.js +1 -1
  33. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  34. package/dist/cli/pack/hook-pre-tool-use.js +1 -1
  35. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  36. package/dist/cli/pack/hook-track-active-claim.js +1 -1
  37. package/dist/cli/pack/hook-track-active-claim.js.map +1 -1
  38. package/dist/cli/{pack/pause-check.d.ts → pause-check.d.ts} +1 -1
  39. package/dist/cli/{pack/pause-check.js → pause-check.js} +14 -11
  40. package/dist/cli/pause-check.js.map +1 -0
  41. package/dist/cli/policy/intercept.d.ts +15 -0
  42. package/dist/cli/policy/intercept.js +55 -1
  43. package/dist/cli/policy/intercept.js.map +1 -1
  44. package/dist/cli/resolve-env.d.ts +32 -0
  45. package/dist/cli/resolve-env.js +47 -0
  46. package/dist/cli/resolve-env.js.map +1 -0
  47. package/dist/cli/test-risk.d.ts +26 -0
  48. package/dist/cli/test-risk.js +34 -0
  49. package/dist/cli/test-risk.js.map +1 -0
  50. package/dist/runtime/action-envelope.d.ts +64 -0
  51. package/dist/runtime/action-envelope.js +46 -0
  52. package/dist/runtime/action-envelope.js.map +1 -0
  53. package/dist/runtime/environment-resolver.d.ts +36 -0
  54. package/dist/runtime/environment-resolver.js +138 -0
  55. package/dist/runtime/environment-resolver.js.map +1 -0
  56. package/dist/runtime/index.d.ts +6 -1
  57. package/dist/runtime/index.js +6 -1
  58. package/dist/runtime/index.js.map +1 -1
  59. package/dist/runtime/intercept.d.ts +60 -3
  60. package/dist/runtime/intercept.js +104 -6
  61. package/dist/runtime/intercept.js.map +1 -1
  62. package/dist/runtime/kube-context.d.ts +16 -0
  63. package/dist/runtime/kube-context.js +63 -0
  64. package/dist/runtime/kube-context.js.map +1 -0
  65. package/dist/runtime/ledger-record.d.ts +8 -0
  66. package/dist/runtime/ledger-record.js +2 -0
  67. package/dist/runtime/ledger-record.js.map +1 -1
  68. package/dist/runtime/risk-classifier.d.ts +38 -0
  69. package/dist/runtime/risk-classifier.js +148 -0
  70. package/dist/runtime/risk-classifier.js.map +1 -0
  71. package/dist/runtime/when-eval.d.ts +40 -0
  72. package/dist/runtime/when-eval.js +134 -0
  73. package/dist/runtime/when-eval.js.map +1 -0
  74. package/dist/schema/environments.d.ts +215 -0
  75. package/dist/schema/environments.js +101 -0
  76. package/dist/schema/environments.js.map +1 -0
  77. package/dist/schema/index.d.ts +419 -11
  78. package/dist/schema/index.js +8 -0
  79. package/dist/schema/index.js.map +1 -1
  80. package/dist/schema/policies.d.ts +152 -13
  81. package/dist/schema/policies.js +52 -1
  82. package/dist/schema/policies.js.map +1 -1
  83. package/dist/schema/risk.d.ts +131 -0
  84. package/dist/schema/risk.js +87 -0
  85. package/dist/schema/risk.js.map +1 -0
  86. package/package.json +1 -1
  87. package/dist/cli/pack/pause-check.js.map +0 -1
@@ -0,0 +1,34 @@
1
+ // Phase 7 #3 — `harness test-risk` CLI entrypoint.
2
+ //
3
+ // Debug verb for the Risk Gate. Reads a tool-event JSON file, builds
4
+ // the Action Envelope (#2), classifies it against the manifest's
5
+ // `risk.classifiers[]` (#3), and prints the resulting risk profile.
6
+ // The inspection surface for the Risk Classifier, the counterpart of
7
+ // `harness explain-action` one stage further down the pipeline.
8
+ //
9
+ // File read, JSON guards, and envelope build are the shared
10
+ // `event-input` front end; manifest load + classification + rendering
11
+ // live here.
12
+ import { stringify as stringifyYaml } from "yaml";
13
+ import { classifyRisk } from "../runtime/index.js";
14
+ import { loadEventEnvelope } from "./event-input.js";
15
+ import { loadManifest } from "./loader.js";
16
+ /**
17
+ * Build the Action Envelope for a tool-event JSON file, classify it
18
+ * against the manifest's risk classifiers, and render the profile.
19
+ *
20
+ * Throws `HarnessExitError(EX_NOINPUT)` when the event file is missing,
21
+ * malformed, or not a JSON object (see `loadEventEnvelope`). A manifest
22
+ * with no `risk.classifiers[]` is valid: every action then classifies
23
+ * as unclassified ("unknown is not safe").
24
+ */
25
+ export function testRisk(opts) {
26
+ const { envelope } = loadEventEnvelope(opts.eventPath, opts, "test-risk");
27
+ const manifest = opts.manifest ?? loadManifest(opts).manifest;
28
+ const profile = classifyRisk(envelope, manifest.risk.classifiers);
29
+ const output = opts.json
30
+ ? `${JSON.stringify(profile, null, 2)}\n`
31
+ : stringifyYaml(profile, { lineWidth: 0 });
32
+ return { output, profile };
33
+ }
34
+ //# sourceMappingURL=test-risk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-risk.js","sourceRoot":"","sources":["../../src/cli/test-risk.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,EAAE;AACF,qEAAqE;AACrE,iEAAiE;AACjE,oEAAoE;AACpE,qEAAqE;AACrE,gEAAgE;AAChE,EAAE;AACF,4DAA4D;AAC5D,sEAAsE;AACtE,aAAa;AAEb,OAAO,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,MAAM,CAAC;AAClD,OAAO,EAAE,YAAY,EAAoB,MAAM,qBAAqB,CAAC;AAErE,OAAO,EAAE,iBAAiB,EAAwB,MAAM,kBAAkB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC;AAgB/D;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAqB;IAC5C,MAAM,EAAE,QAAQ,EAAE,GAAG,iBAAiB,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,WAAW,CAAC,CAAC;IAC1E,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,YAAY,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC;IAC9D,MAAM,OAAO,GAAG,YAAY,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IAClE,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI;QACtB,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI;QACzC,CAAC,CAAC,aAAa,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC;IAC7C,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AAC7B,CAAC"}
@@ -0,0 +1,64 @@
1
+ import type { GitRepoContext } from "./git-context.js";
2
+ import type { ToolEvent } from "./intercept.js";
3
+ export interface ActionEnvelopeSession {
4
+ /** Grounding / Claude Code session id, or "" when absent. */
5
+ id: string;
6
+ /** Work-tree basename for the event's cwd, or "" when not in a repo. */
7
+ repo: string;
8
+ /** Current branch, or "" when not in a repo or HEAD is detached. */
9
+ branch: string;
10
+ /**
11
+ * agent-tasks task id. Not present in the Claude Code PreToolUse
12
+ * payload, so "" in practice; a harness-driven or synthetic event may
13
+ * carry one. The MVP does no agent-tasks lookup.
14
+ */
15
+ task_id: string;
16
+ }
17
+ export interface ActionEnvelopeRuntime {
18
+ /** Working directory the action runs in. */
19
+ cwd: string;
20
+ /** OS user, or "" when unavailable. */
21
+ user: string;
22
+ /** Host name, or "" when unavailable. */
23
+ host: string;
24
+ }
25
+ export interface ActionEnvelope {
26
+ /** Hook event name, e.g. "PreToolUse". "" when absent. */
27
+ event: string;
28
+ /** Tool name, e.g. "Bash". "" when absent. */
29
+ tool: string;
30
+ /** The tool's raw input, verbatim. `null` when the event carries none. */
31
+ raw_input: unknown;
32
+ session: ActionEnvelopeSession;
33
+ runtime: ActionEnvelopeRuntime;
34
+ /** ISO-8601 UTC timestamp the envelope was built. */
35
+ timestamp: string;
36
+ }
37
+ /**
38
+ * Ambient facts the builder cannot derive from the event alone. The CLI
39
+ * wrapper resolves these (filesystem + process reads) and hands them in,
40
+ * keeping `buildActionEnvelope` itself pure and I/O-free — the same
41
+ * resolved-by-the-wrapper pattern `intercept()` uses for the git sha and
42
+ * the policy builtins.
43
+ */
44
+ export interface EnvelopeContext {
45
+ /** Final working directory: the event's cwd, or the wrapper's fallback. */
46
+ cwd: string;
47
+ /** Git context resolved against `cwd`. Empty strings when cwd is not in a repo. */
48
+ git: GitRepoContext;
49
+ /** OS user (`os.userInfo().username`), or "" when unavailable. */
50
+ user: string;
51
+ /** Host name (`os.hostname()`), or "" when unavailable. */
52
+ host: string;
53
+ /** Timestamp to stamp on the envelope. */
54
+ now: Date;
55
+ }
56
+ /**
57
+ * Normalize a runtime tool event into an Action Envelope.
58
+ *
59
+ * Pure: every non-deterministic input (cwd, git, user, host, now)
60
+ * arrives via `context`. A sparse or malformed event never throws —
61
+ * absent fields become "" (or `null` for `raw_input`), so a hand-probed
62
+ * `{}` event still yields a well-formed envelope.
63
+ */
64
+ export declare function buildActionEnvelope(event: ToolEvent, context: EnvelopeContext): ActionEnvelope;
@@ -0,0 +1,46 @@
1
+ // Phase 7 #2 — Action Envelope.
2
+ //
3
+ // The normalized, stable representation of a tool call that the Risk
4
+ // Gate pipeline reasons about. The raw runtime event (`ToolEvent`, the
5
+ // Claude Code PreToolUse hook payload) is runtime-specific and loosely
6
+ // shaped; every downstream Risk Gate stage — the Risk Classifier (#3),
7
+ // Context Resolver (#4), Policy Evaluator (#5) — consumes THIS shape
8
+ // instead, so none of them re-parse a runtime-specific payload.
9
+ //
10
+ // STATUS: built by `harness explain-action` (Phase 7 #2). NOT yet
11
+ // consumed by `harness policy intercept` — routing the runtime through
12
+ // the envelope is Phase 7 #5. See docs/risk-gate.md and docs/ROADMAP.md.
13
+ //
14
+ // Design source: lava-ice-logs/2026-04-30/harness-risk-gate-extension.md
15
+ // ("Action Envelope" section).
16
+ function asString(v) {
17
+ return typeof v === "string" ? v : "";
18
+ }
19
+ /**
20
+ * Normalize a runtime tool event into an Action Envelope.
21
+ *
22
+ * Pure: every non-deterministic input (cwd, git, user, host, now)
23
+ * arrives via `context`. A sparse or malformed event never throws —
24
+ * absent fields become "" (or `null` for `raw_input`), so a hand-probed
25
+ * `{}` event still yields a well-formed envelope.
26
+ */
27
+ export function buildActionEnvelope(event, context) {
28
+ return {
29
+ event: asString(event.hook_event_name),
30
+ tool: asString(event.tool_name),
31
+ raw_input: event.tool_input ?? null,
32
+ session: {
33
+ id: asString(event.session_id),
34
+ repo: context.git.repo,
35
+ branch: context.git.branch,
36
+ task_id: asString(event.task_id),
37
+ },
38
+ runtime: {
39
+ cwd: context.cwd,
40
+ user: context.user,
41
+ host: context.host,
42
+ },
43
+ timestamp: context.now.toISOString(),
44
+ };
45
+ }
46
+ //# sourceMappingURL=action-envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action-envelope.js","sourceRoot":"","sources":["../../src/runtime/action-envelope.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,EAAE;AACF,qEAAqE;AACrE,uEAAuE;AACvE,uEAAuE;AACvE,uEAAuE;AACvE,qEAAqE;AACrE,gEAAgE;AAChE,EAAE;AACF,kEAAkE;AAClE,uEAAuE;AACvE,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,+BAA+B;AA8D/B,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAgB,EAChB,OAAwB;IAExB,OAAO;QACL,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC,eAAe,CAAC;QACtC,IAAI,EAAE,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC;QAC/B,SAAS,EAAE,KAAK,CAAC,UAAU,IAAI,IAAI;QACnC,OAAO,EAAE;YACP,EAAE,EAAE,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC;YAC9B,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI;YACtB,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM;YAC1B,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC;SACjC;QACD,OAAO,EAAE;YACP,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,IAAI,EAAE,OAAO,CAAC,IAAI;SACnB;QACD,SAAS,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE;KACrC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,36 @@
1
+ import type { EnvironmentResolver, MatchableEnvironment } from "../schema/index.js";
2
+ import type { ActionEnvelope } from "./action-envelope.js";
3
+ export type EnvironmentConfidence = "high" | "medium" | "low";
4
+ export interface EnvironmentResolution {
5
+ /** Resolved environment, or `unknown` when no resolver matched. */
6
+ name: MatchableEnvironment;
7
+ /**
8
+ * `high` when two or more signals (unioned across every resolver
9
+ * asserting the winning environment) back the result, `medium` for a
10
+ * single signal, `low` for `unknown` (no signal matched at all).
11
+ */
12
+ confidence: EnvironmentConfidence;
13
+ /** Matched signal descriptors that back the result, for explainability. */
14
+ signals: string[];
15
+ /** Name of the resolver whose environment won, or `null` when unresolved. */
16
+ resolver: string | null;
17
+ }
18
+ /** Ambient inputs the resolver matches signals against. */
19
+ export interface SignalInputs {
20
+ /** Environment variables, for `env_var_patterns`. */
21
+ env: Record<string, string | undefined>;
22
+ /** Current kube context name, for `kube_context_patterns`. "" when unknown. */
23
+ kubeContext: string;
24
+ /** Current kube namespace, for `kube_namespace_patterns`. "" when unknown. */
25
+ kubeNamespace: string;
26
+ }
27
+ /**
28
+ * Resolve the target environment of an Action Envelope.
29
+ *
30
+ * Pure: envelope + resolvers + ambient inputs in, resolution out, no
31
+ * I/O. When resolvers disagree, the most-dangerous environment wins
32
+ * (`ENV_PRECEDENCE`). Signals from every fired resolver that asserts the
33
+ * winning environment are unioned for explainability. When nothing
34
+ * matches, the result is `unknown` — deliberately not a safe default.
35
+ */
36
+ export declare function resolveEnvironment(envelope: ActionEnvelope, resolvers: readonly EnvironmentResolver[], inputs: SignalInputs): EnvironmentResolution;
@@ -0,0 +1,138 @@
1
+ // Phase 7 #4 — Context Resolver.
2
+ //
3
+ // Classifies an Action Envelope's target environment by matching the
4
+ // manifest's `environments.resolvers[]` signals. The Risk Gate stage
5
+ // that reads the `environments:` schema vocabulary shipped in
6
+ // Phase 7 #1.
7
+ //
8
+ // STATUS: invoked by `harness resolve-env` (Phase 7 #4). NOT yet
9
+ // consumed by `harness policy intercept` — wiring the runtime through
10
+ // the resolver is Phase 7 #5. See docs/risk-gate.md and docs/ROADMAP.md.
11
+ //
12
+ // "Unknown is not safe": when no resolver matches, the environment is
13
+ // `unknown` — not a safe default. The Phase 7 #5 policy evaluator must
14
+ // treat `unknown` as risk-bearing. `MatchableEnvironmentSchema` already
15
+ // admits `unknown` so a `policy.when.environment.name` clause can gate
16
+ // on it.
17
+ //
18
+ // Design source: lava-ice-logs/2026-04-30/harness-risk-gate-extension.md
19
+ // (design phase C).
20
+ // Most-dangerous-wins precedence: when resolvers disagree, the earlier
21
+ // entry here is the resolved environment. `unknown` is the implicit
22
+ // fallback and is not a resolver-assertable value.
23
+ const ENV_PRECEDENCE = [
24
+ "production",
25
+ "staging",
26
+ "dev",
27
+ "local",
28
+ ];
29
+ /**
30
+ * Convert a `*`-glob to an anchored RegExp. Only `*` is special (the
31
+ * Phase 7 #1 signal patterns are simple, e.g. `main`, `release/*`); all
32
+ * other regex metacharacters are escaped literally.
33
+ */
34
+ function globToRegExp(glob) {
35
+ const escaped = glob
36
+ .replace(/[.+^${}()|[\]\\?]/g, "\\$&")
37
+ .replace(/\*/g, ".*");
38
+ return new RegExp(`^${escaped}$`);
39
+ }
40
+ /**
41
+ * Match one resolver's signals against the envelope + ambient inputs.
42
+ * Signals are OR-ed: a resolver fires when ANY signal matches. Returns
43
+ * the matched signal descriptors (empty when the resolver did not fire).
44
+ *
45
+ * Per-signal-kind semantics: `branch_patterns` and
46
+ * `kube_namespace_patterns` are globs, `kube_context_patterns` are
47
+ * regexes, `env_var_patterns` are substrings of the variable's value.
48
+ */
49
+ function matchResolver(resolver, envelope, inputs) {
50
+ const matched = [];
51
+ const sig = resolver.signals;
52
+ const branch = envelope.session.branch;
53
+ if (sig.branch_patterns !== undefined && branch !== "") {
54
+ for (const p of sig.branch_patterns) {
55
+ if (globToRegExp(p).test(branch)) {
56
+ matched.push(`branch:${branch} ~ ${p}`);
57
+ }
58
+ }
59
+ }
60
+ if (sig.env_var_patterns !== undefined) {
61
+ for (const evp of sig.env_var_patterns) {
62
+ const value = inputs.env[evp.var];
63
+ if (typeof value !== "string")
64
+ continue;
65
+ for (const p of evp.patterns) {
66
+ if (value.includes(p)) {
67
+ matched.push(`env:${evp.var} contains "${p}"`);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ if (sig.kube_context_patterns !== undefined && inputs.kubeContext !== "") {
73
+ for (const p of sig.kube_context_patterns) {
74
+ let re;
75
+ try {
76
+ re = new RegExp(p);
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ if (re.test(inputs.kubeContext)) {
82
+ matched.push(`kube-context:${inputs.kubeContext} ~ /${p}/`);
83
+ }
84
+ }
85
+ }
86
+ if (sig.kube_namespace_patterns !== undefined &&
87
+ inputs.kubeNamespace !== "") {
88
+ for (const p of sig.kube_namespace_patterns) {
89
+ if (globToRegExp(p).test(inputs.kubeNamespace)) {
90
+ matched.push(`kube-namespace:${inputs.kubeNamespace} ~ ${p}`);
91
+ }
92
+ }
93
+ }
94
+ return matched;
95
+ }
96
+ /**
97
+ * Resolve the target environment of an Action Envelope.
98
+ *
99
+ * Pure: envelope + resolvers + ambient inputs in, resolution out, no
100
+ * I/O. When resolvers disagree, the most-dangerous environment wins
101
+ * (`ENV_PRECEDENCE`). Signals from every fired resolver that asserts the
102
+ * winning environment are unioned for explainability. When nothing
103
+ * matches, the result is `unknown` — deliberately not a safe default.
104
+ */
105
+ export function resolveEnvironment(envelope, resolvers, inputs) {
106
+ const fired = [];
107
+ for (const resolver of resolvers) {
108
+ const signals = matchResolver(resolver, envelope, inputs);
109
+ if (signals.length > 0)
110
+ fired.push({ resolver, signals });
111
+ }
112
+ if (fired.length === 0) {
113
+ return { name: "unknown", confidence: "low", signals: [], resolver: null };
114
+ }
115
+ let best = fired[0];
116
+ for (const f of fired) {
117
+ if (ENV_PRECEDENCE.indexOf(f.resolver.environment) <
118
+ ENV_PRECEDENCE.indexOf(best.resolver.environment)) {
119
+ best = f;
120
+ }
121
+ }
122
+ // Union the signals of every fired resolver that asserts the winning
123
+ // environment, so the explanation is complete when several resolvers
124
+ // agree. `resolver` names the highest-precedence (first-found) one.
125
+ const winningEnv = best.resolver.environment;
126
+ const signals = [
127
+ ...new Set(fired
128
+ .filter((f) => f.resolver.environment === winningEnv)
129
+ .flatMap((f) => f.signals)),
130
+ ].sort();
131
+ return {
132
+ name: winningEnv,
133
+ confidence: signals.length >= 2 ? "high" : "medium",
134
+ signals,
135
+ resolver: best.resolver.name,
136
+ };
137
+ }
138
+ //# sourceMappingURL=environment-resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environment-resolver.js","sourceRoot":"","sources":["../../src/runtime/environment-resolver.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,11 @@
1
- export { intercept, type ClaudeDenyJson, type InterceptOptions, type InterceptResult, type LedgerClient, type PolicyDecision, type PolicyOutcome, type ToolEvent, } from "./intercept.js";
1
+ export { intercept, policyMatchesEvent, type ClaudeDenyJson, type InterceptOptions, type InterceptResult, type LedgerClient, type PolicyDecision, type PolicyOutcome, type RiskGateContext, type ToolEvent, } from "./intercept.js";
2
+ export { evaluateWhen, type WhenClauseKey, type WhenClauseResult, type WhenContext, type WhenEvaluation, } from "./when-eval.js";
2
3
  export { recordPolicyDecision, payloadFromDecision, encodeLedgerContent, decodeLedgerContent, decisionSortKey, type LedgerRecordOptions, type PolicyDecisionPayload, } from "./ledger-record.js";
3
4
  export { resolveSessionId } from "./session-id.js";
4
5
  export { buildAgentFacingBlock, formatAgentFacingMessage, renderAgentFacing, type AgentFacingBlock, } from "./agent-facing.js";
5
6
  export { resolveGitContext, type GitRepoContext } from "./git-context.js";
7
+ export { buildActionEnvelope, type ActionEnvelope, type ActionEnvelopeRuntime, type ActionEnvelopeSession, type EnvelopeContext, } from "./action-envelope.js";
8
+ export { classifyRisk, type RiskConfidence, type RiskProfile, } from "./risk-classifier.js";
9
+ export { resolveKubeContext, type KubeContext, type ResolveKubeContextOptions, } from "./kube-context.js";
10
+ export { resolveEnvironment, type EnvironmentConfidence, type EnvironmentResolution, type SignalInputs, } from "./environment-resolver.js";
6
11
  export { addLedgerFact, type AddLedgerFactOptions, type AddLedgerFactResult, } from "./ledger-add.js";
@@ -1,7 +1,12 @@
1
- export { intercept, } from "./intercept.js";
1
+ export { intercept, policyMatchesEvent, } from "./intercept.js";
2
+ export { evaluateWhen, } from "./when-eval.js";
2
3
  export { recordPolicyDecision, payloadFromDecision, encodeLedgerContent, decodeLedgerContent, decisionSortKey, } from "./ledger-record.js";
3
4
  export { resolveSessionId } from "./session-id.js";
4
5
  export { buildAgentFacingBlock, formatAgentFacingMessage, renderAgentFacing, } from "./agent-facing.js";
5
6
  export { resolveGitContext } from "./git-context.js";
7
+ export { buildActionEnvelope, } from "./action-envelope.js";
8
+ export { classifyRisk, } from "./risk-classifier.js";
9
+ export { resolveKubeContext, } from "./kube-context.js";
10
+ export { resolveEnvironment, } from "./environment-resolver.js";
6
11
  export { addLedgerFact, } from "./ledger-add.js";
7
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,GAQV,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,GAGhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EACL,qBAAqB,EACrB,wBAAwB,EACxB,iBAAiB,GAElB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,iBAAiB,EAAuB,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,aAAa,GAGd,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,kBAAkB,GASnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,YAAY,GAKb,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,GAGhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EACL,qBAAqB,EACrB,wBAAwB,EACxB,iBAAiB,GAElB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,iBAAiB,EAAuB,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,mBAAmB,GAKpB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,YAAY,GAGb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,kBAAkB,GAGnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,kBAAkB,GAInB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,aAAa,GAGd,MAAM,iBAAiB,CAAC"}
@@ -1,5 +1,8 @@
1
1
  import { type ExtractBuiltins, type LedgerQueryResult } from "../policies/index.js";
2
- import type { Manifest } from "../schema/index.js";
2
+ import type { Manifest, Policy } from "../schema/index.js";
3
+ import { type EnvironmentResolution } from "./environment-resolver.js";
4
+ import type { GitRepoContext } from "./git-context.js";
5
+ import { type RiskProfile } from "./risk-classifier.js";
3
6
  export interface ToolEvent {
4
7
  hook_event_name?: string;
5
8
  tool_name?: string;
@@ -8,10 +11,10 @@ export interface ToolEvent {
8
11
  cwd?: string;
9
12
  [key: string]: unknown;
10
13
  }
11
- export type PolicyOutcome = "allow" | "deny" | "warn-degraded";
14
+ export type PolicyOutcome = "allow" | "warn" | "require_approval" | "deny" | "warn-degraded";
12
15
  export interface PolicyDecision {
13
16
  policyName: string;
14
- enforcement: "block" | "warn";
17
+ enforcement: Policy["enforcement"];
15
18
  outcome: PolicyOutcome;
16
19
  reason: string;
17
20
  extractValues: Record<string, string>;
@@ -20,6 +23,17 @@ export interface PolicyDecision {
20
23
  matchedCount: number;
21
24
  reason: string;
22
25
  };
26
+ /**
27
+ * Risk Classifier verdict for the action this decision was made
28
+ * about. Present only when the Risk Gate was active for the event
29
+ * (the manifest declared at least one `when:`-bearing policy); absent
30
+ * for a pure Phase-4 manifest, keeping its decisions byte-identical.
31
+ * Recorded to the audit ledger so `harness explain --trace` can
32
+ * replay the classification.
33
+ */
34
+ risk?: RiskProfile;
35
+ /** Context Resolver verdict, present under the same condition as `risk`. */
36
+ environment?: EnvironmentResolution;
23
37
  /**
24
38
  * One-line "to satisfy" hint synthesised from the policy's `requires`
25
39
  * spec. Carried on the live decision so the deny-envelope formatter
@@ -84,5 +98,48 @@ export interface InterceptOptions {
84
98
  * branch falls through to the standard time-window check.
85
99
  */
86
100
  currentHeadSha?: string;
101
+ /**
102
+ * Ambient context for the Risk Gate stages — the Action Envelope
103
+ * build (#2) and the environment resolution (#4). Resolved by the CLI
104
+ * wrapper (git / user / host / kube-config / env reads) and threaded
105
+ * in, keeping `intercept()` itself I/O-free, the same
106
+ * resolved-by-the-wrapper pattern as `currentHeadSha` and `builtins`.
107
+ *
108
+ * Optional: omitted by Phase-4-era callers and by unit tests that do
109
+ * not exercise `when:`. When omitted, the envelope is built from the
110
+ * event alone — risk then classifies as unclassified and the
111
+ * environment resolves to `unknown`. A manifest with no `when:`
112
+ * policy never reads any of this regardless (see `intercept`).
113
+ */
114
+ riskContext?: RiskGateContext;
87
115
  }
116
+ /**
117
+ * Ambient inputs the Risk Gate needs that the CLI wrapper resolves from
118
+ * the host (filesystem + process). Mirrors the `EnvelopeContext` /
119
+ * `SignalInputs` split the debug verbs already use.
120
+ */
121
+ export interface RiskGateContext {
122
+ /** Git context resolved against the event's cwd. */
123
+ git: GitRepoContext;
124
+ /** Working directory the action runs in. */
125
+ cwd: string;
126
+ /** OS user, or "" when unavailable. */
127
+ user: string;
128
+ /** Host name, or "" when unavailable. */
129
+ host: string;
130
+ /** Environment variables, for resolver `env_var_patterns`. */
131
+ env: Record<string, string | undefined>;
132
+ /** Current kube context name, or "" when unknown. */
133
+ kubeContext: string;
134
+ /** Current kube namespace, or "" when unknown. */
135
+ kubeNamespace: string;
136
+ }
137
+ /**
138
+ * Does a policy's `trigger:` match this event? This is the WHICH-tool-
139
+ * calls filter; the WHETHER-it-applies filter is `policy.when:`,
140
+ * evaluated separately (`evaluateWhen`). A policy fires only when both
141
+ * hold. Exported so `harness explain-policy` can report the trigger
142
+ * verdict on its own.
143
+ */
144
+ export declare function policyMatchesEvent(policy: Policy, event: ToolEvent): boolean;
88
145
  export declare function intercept(options: InterceptOptions): Promise<InterceptResult>;
@@ -7,11 +7,45 @@
7
7
  // that wraps this.
8
8
  import { evaluateExtract, evaluateRequires, parseDurationSeconds, substituteTemplate, } from "../policies/index.js";
9
9
  import { renderProducers } from "../policies/producers.js";
10
+ import { buildActionEnvelope } from "./action-envelope.js";
10
11
  import { renderAgentFacing } from "./agent-facing.js";
12
+ import { resolveEnvironment, } from "./environment-resolver.js";
11
13
  import { POLICY_DECISION_TYPE } from "./ledger-record.js";
14
+ import { classifyRisk } from "./risk-classifier.js";
12
15
  import { resolveSessionId } from "./session-id.js";
13
16
  import { expandToolNameAliases, extractShellCommand, } from "./tool-name-aliases.js";
14
- function policyMatchesEvent(policy, event) {
17
+ import { evaluateWhen } from "./when-eval.js";
18
+ /**
19
+ * Build the Action Envelope for an event and run it through the Risk
20
+ * Classifier (#3) and Context Resolver (#4). Pure: every host fact
21
+ * arrives via `riskContext`; when it is absent the envelope is built
22
+ * from the event alone (unclassified risk, `unknown` environment).
23
+ */
24
+ function enrichEnvelope(manifest, event, riskContext, now) {
25
+ const rc = riskContext;
26
+ const envelope = buildActionEnvelope(event, {
27
+ cwd: rc?.cwd ?? (typeof event.cwd === "string" ? event.cwd : ""),
28
+ git: rc?.git ?? { repo: "", branch: "", sha: "" },
29
+ user: rc?.user ?? "",
30
+ host: rc?.host ?? "",
31
+ now: now ?? new Date(),
32
+ });
33
+ const risk = classifyRisk(envelope, manifest.risk.classifiers);
34
+ const environment = resolveEnvironment(envelope, manifest.environments.resolvers, {
35
+ env: rc?.env ?? {},
36
+ kubeContext: rc?.kubeContext ?? "",
37
+ kubeNamespace: rc?.kubeNamespace ?? "",
38
+ });
39
+ return { risk, environment };
40
+ }
41
+ /**
42
+ * Does a policy's `trigger:` match this event? This is the WHICH-tool-
43
+ * calls filter; the WHETHER-it-applies filter is `policy.when:`,
44
+ * evaluated separately (`evaluateWhen`). A policy fires only when both
45
+ * hold. Exported so `harness explain-policy` can report the trigger
46
+ * verdict on its own.
47
+ */
48
+ export function policyMatchesEvent(policy, event) {
15
49
  if (policy.trigger.event !== event.hook_event_name)
16
50
  return false;
17
51
  if (policy.trigger.match !== undefined) {
@@ -46,6 +80,35 @@ function buildEventContext(event) {
46
80
  git: {},
47
81
  };
48
82
  }
83
+ /** Map a failed-`requires` policy to its decision outcome by enforcement. */
84
+ function outcomeForFailedRequires(enforcement) {
85
+ switch (enforcement) {
86
+ case "block":
87
+ return "deny";
88
+ case "warn":
89
+ return "warn";
90
+ case "require_approval":
91
+ return "require_approval";
92
+ }
93
+ }
94
+ /**
95
+ * Does a decision abort the tool call? Phase 7 #6 makes the Risk Gate
96
+ * authoritative at the `PreToolUse` boundary:
97
+ * - `deny` aborts (a `block`-enforcement policy whose requires failed,
98
+ * the Phase 4 mechanism, unchanged).
99
+ * - `require_approval` aborts until the approval evidence exists. In
100
+ * Phase 7 #5 this outcome was returned but did not block; #6 makes
101
+ * it block. The approval tag is satisfiable through the policy's
102
+ * `requires:` (an operator runs `harness approve risk`); once the
103
+ * tag is on record the requires evaluation passes and the outcome
104
+ * is `allow` instead.
105
+ * - `allow` / `warn` / `warn-degraded` never abort.
106
+ */
107
+ function isBlockingDecision(d) {
108
+ if (d.outcome === "deny")
109
+ return d.enforcement === "block";
110
+ return d.outcome === "require_approval";
111
+ }
49
112
  async function evaluateOnePolicy(policy, options) {
50
113
  const evaluatedAt = (options.now ?? new Date()).toISOString();
51
114
  const ctx = buildEventContext(options.event);
@@ -130,7 +193,14 @@ async function evaluateOnePolicy(policy, options) {
130
193
  evaluatedAt,
131
194
  };
132
195
  }
133
- const outcome = evaluation.allowed ? "allow" : "deny";
196
+ // Four-way decision (Phase 7 #5). A satisfied `requires` always
197
+ // `allow`s; a failed one is mapped by the policy's enforcement —
198
+ // `block` → `deny`, `warn` → `warn`, `require_approval` →
199
+ // `require_approval`. The evaluator only RETURNS `require_approval`
200
+ // here; Phase 7 #6 makes it block.
201
+ const outcome = evaluation.allowed
202
+ ? "allow"
203
+ : outcomeForFailedRequires(policy.enforcement);
134
204
  return {
135
205
  policyName: policy.name,
136
206
  enforcement: policy.enforcement,
@@ -168,19 +238,47 @@ function filterEntriesByTag(entries, tag) {
168
238
  (e.source !== undefined && e.source.includes(tag))));
169
239
  }
170
240
  export async function intercept(options) {
171
- const matching = options.manifest.policies.filter((p) => policyMatchesEvent(p, options.event));
241
+ const { manifest, event } = options;
242
+ // The Risk Gate is active only when some policy declares a `when:`
243
+ // block. A manifest with none — every Phase 4 / 5 / 6 manifest — skips
244
+ // envelope enrichment entirely: no `buildActionEnvelope`, no
245
+ // classifier, no resolver, and decisions carry no `risk` / `environment`.
246
+ // That keeps such manifests byte-for-byte identical to pre-Phase-7-#5.
247
+ const riskGateActive = manifest.policies.some((p) => p.when !== undefined);
248
+ const enriched = riskGateActive
249
+ ? enrichEnvelope(manifest, event, options.riskContext, options.now)
250
+ : undefined;
251
+ // A policy fires only when its `trigger:` matches AND — when declared
252
+ // — every `when:` clause holds against the enriched envelope.
253
+ const matching = manifest.policies.filter((p) => {
254
+ if (!policyMatchesEvent(p, event))
255
+ return false;
256
+ if (p.when === undefined)
257
+ return true;
258
+ // `enriched` is defined here: a policy with `when:` set `riskGateActive`.
259
+ return evaluateWhen(p.when, enriched).matched;
260
+ });
172
261
  const decisions = [];
173
262
  for (const policy of matching) {
174
- const decision = await evaluateOnePolicy(policy, options);
263
+ const base = await evaluateOnePolicy(policy, options);
264
+ // Attach the per-event Risk Gate verdicts so `harness audit` /
265
+ // `explain --trace` can replay the classification + environment
266
+ // that the `when:` match was made against.
267
+ const decision = enriched
268
+ ? { ...base, risk: enriched.risk, environment: enriched.environment }
269
+ : base;
175
270
  decisions.push(decision);
176
271
  try {
177
- await options.ledger.record(decision, resolveSessionId(options.event.session_id));
272
+ await options.ledger.record(decision, resolveSessionId(event.session_id));
178
273
  }
179
274
  catch {
180
275
  /* audit-write failure must not block; the decision is still applied. */
181
276
  }
182
277
  }
183
- const blocking = decisions.find((d) => d.enforcement === "block" && d.outcome === "deny");
278
+ // First blocking decision wins the envelope. `deny` and
279
+ // `require_approval` both abort (Phase 7 #6); the search order is the
280
+ // manifest's policy order, same as Phase 4.
281
+ const blocking = decisions.find(isBlockingDecision);
184
282
  if (blocking) {
185
283
  const sessionId = resolveSessionId(options.event.session_id);
186
284
  // Append the "to satisfy" hint so Claude Code's deny message tells